Add Battlesuit page

This commit is contained in:
Sakura Knoll
2023-07-09 07:31:28 +09:00 Unverified
parent 9ce20302a3
commit f6f2a0866d
21 changed files with 1463 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import { Image, ThemeUIStyleObject } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
import { AttributeType } from '../../lib/v2-pre/data/types'
interface AttributeIconProps {
attributeType: AttributeType
size?: number
className?: string
sx?: ThemeUIStyleObject
}
const AttributeIcon = ({ size, attributeType, className, sx }: AttributeIconProps) => {
return (
<Image src={getAttributeIconSrc(attributeType)} width={size} className={className} sx={sx} alt={attributeType} />
)
}
export default AttributeIcon
function getAttributeIconSrc(attributeType: AttributeType) {
switch (attributeType) {
case 'bio':
return `${assetsBucketBaseUrl}/raw/avatarattricons/AvatarShengWu.png`
case 'psy':
return `${assetsBucketBaseUrl}/raw/avatarattricons/AvatarYiNeng.png`
case 'mech':
return `${assetsBucketBaseUrl}/raw/avatarattricons/AvatarJiXie.png`
case 'qua':
return `${assetsBucketBaseUrl}/raw/avatarattricons/AvatarLiangZi.png`
case 'img':
return `${assetsBucketBaseUrl}/raw/avatarattricons/AvatarXuShu.png`
case 'none':
default:
return `${assetsBucketBaseUrl}/raw/avatarattricons/AvatarNone.png`
}
}
@@ -0,0 +1,21 @@
import { Image, ThemeUICSSObject } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
interface AvatarFigureImageProps {
battlesuitId: string
className?: string
sx?: ThemeUICSSObject
}
const AvatarFigureImage = ({ battlesuitId, className, sx }: AvatarFigureImageProps) => {
return <Image src={getAvatarFigureImageSrc(battlesuitId)} className={className} alt={battlesuitId} sx={sx} />
}
export default AvatarFigureImage
function getAvatarFigureImageSrc(battlesuitId: string) {
if (battlesuitId.length < 4) {
return `${assetsBucketBaseUrl}/raw/avatarcardfigures/60${battlesuitId}.png`
}
return `${assetsBucketBaseUrl}/raw/avatarcardfigures/6${battlesuitId}.png`
}
+20
View File
@@ -0,0 +1,20 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
interface AvatarSkillIconProps {
icon: string
}
const AvatarSkillIcon = ({ icon: fileName }: AvatarSkillIconProps) => {
return (
<Box
sx={{
width: 40,
height: 40,
background: `no-repeat 50%/80% url(${assetsBucketBaseUrl}/raw/skillicons/${fileName}.png)`
}}
/>
)
}
export default AvatarSkillIcon
@@ -0,0 +1,20 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
interface AvatarSubSkillIconProps {
icon: string
}
const AvatarSubSkillIcon = ({ icon: fileName }: AvatarSubSkillIconProps) => {
return (
<Box
sx={{
width: 40,
height: 40,
background: `no-repeat 50%/80% url(${assetsBucketBaseUrl}/raw/subskillicons/${fileName}.png)`
}}
/>
)
}
export default AvatarSubSkillIcon
@@ -0,0 +1,61 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
import { AttributeType, BattlesuitCatalogItem } from '../../lib/v2-pre/data/types'
interface BattlesuitAvatarIconProps {
battlesuit: BattlesuitCatalogItem
}
const BattlesuitAvatarIcon = ({ battlesuit }: BattlesuitAvatarIconProps) => {
return (
<Box>
<Box
sx={{
borderRadius: 5,
background: `no-repeat 50% / 130px url(${assetsBucketBaseUrl}/raw/avataricon/${
battlesuit.id
}.png), no-repeat 50% / 130px url(${getAttributeBgImageSrc(battlesuit.attributeType)})`,
width: 130,
height: 120
}}
></Box>
</Box>
)
}
export default BattlesuitAvatarIcon
function getAttributeBgImageSrc(attrId: AttributeType, square = false) {
if (square) {
switch (attrId) {
case 'bio':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrShengWu1.png`
case 'psy':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrYiNeng1.png`
case 'mech':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrJiXie1.png`
case 'qua':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrLiangZi1.png`
case 'img':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrXuShu1.png`
case 'none':
default:
return `${assetsBucketBaseUrl}/raw/avataricon/AttrDefault.png`
}
}
switch (attrId) {
case 'bio':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrShengWu.png`
case 'psy':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrYiNeng.png`
case 'mech':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrJiXie.png`
case 'qua':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrLiangZi.png`
case 'img':
return `${assetsBucketBaseUrl}/raw/avataricon/AttrXuShu.png`
case 'none':
default:
return `${assetsBucketBaseUrl}/raw/avataricon/AttrDefault.png`
}
}
@@ -0,0 +1,39 @@
import { Box } from 'theme-ui'
import { BattlesuitCatalogItem } from '../../lib/v2-pre/data/types'
import BattlesuitAvatarIcon from './BattlesuitAvatarIcon'
interface BattlesuitCatalogItemCardProps {
battlesuit: BattlesuitCatalogItem
label?: boolean
}
const BattlesuitCatalogItemCard = ({ battlesuit, label = false }: BattlesuitCatalogItemCardProps) => {
return (
<Box
sx={{
mb: 2,
transform: 'scale(1)',
transition: 'transform 200ms ease-in-out',
'&:hover': {
transform: 'scale(1.05)'
}
}}
>
<BattlesuitAvatarIcon battlesuit={battlesuit} />
<Box
sx={{
textAlign: 'center',
overflow: 'hidden',
width: 110,
marginLeft: -10,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{battlesuit.fullName}
</Box>
</Box>
)
}
export default BattlesuitCatalogItemCard
+90
View File
@@ -0,0 +1,90 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
import { CharacterType } from '../../lib/v2-pre/data/types'
interface ChibiIconProps {
id: string
}
const ChibiIcon = ({ id }: ChibiIconProps) => {
return (
<Box
sx={{
width: 30,
height: 30,
background: `no-repeat 50% 50% / 30px url(${assetsBucketBaseUrl}/raw/avatarchibiicons/${getChibiIconName(
id
)}.png)`
}}
></Box>
)
}
export default ChibiIcon
export function getChibiIconName(idOrType: string | CharacterType) {
switch (idOrType) {
case 'kiana':
return '106'
case 'mei':
return '206'
case 'bronya':
return '317'
case 'himeko':
return '412'
case 'theresa':
return '502_05_01'
case 'fuhua':
return '612'
case 'rita':
return '706'
case 'sakura':
return '212'
case 'kallen':
return '111'
case 'olenyevas':
return '422_05_01'
case 'seele':
return '713'
case 'durandal':
return '804'
case 'fischl':
return '2101'
case 'elysia':
return '2202'
case 'mobius':
return '2301'
case 'raven':
return '2401'
case 'carole':
return '2501'
case 'pardofelis':
return '2601'
case 'aponia':
return '2701'
case 'eden':
return '2801'
case 'griseo':
return '2901'
case 'vill-v':
return '3001'
case 'sushang':
return '3101'
case 'ai':
return '3201'
case 'susannah':
return '3301'
case 'hare':
return '3401'
case 'prometheus':
return '3501'
case 'kira':
return '3601'
case 'asuka':
return '901'
case 'keqing':
return '2001'
default:
return 'IconAvatarQuestion'
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Text } from 'theme-ui'
import { FormattedTextToken, tokenize } from '../../lib/v2-pre/formattedText/tokenize'
interface FormattedTextProps {
children: string
}
const FormattedText = ({ children }: FormattedTextProps) => {
const tokens = tokenize(children)
return <>{renderTokens(tokens)}</>
function renderTokens(tokens: FormattedTextToken[]) {
return tokens.map((token, index) => {
if (token.type === 'element') {
return (
<Text key={index} style={{ color: adjustColor(token.attributes.color) }}>
{renderTokens(token.children)}
</Text>
)
}
return token.text
})
}
}
export default FormattedText
function adjustColor(color: unknown): string | undefined {
if (typeof color !== 'string') {
return undefined
}
const normalizedColor = color.toUpperCase()
if (normalizedColor.startsWith('#FEDF4C')) {
return '#e6c010'
}
if (normalizedColor.startsWith('#FFC741')) {
return '#f0a030'
}
if (
normalizedColor.startsWith('#23B2E') ||
normalizedColor.startsWith('#23B2E') ||
normalizedColor.startsWith('#23B2E')
) {
return '#1d9ecc'
}
return color
}
+48
View File
@@ -0,0 +1,48 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
import { StarRank } from '../../lib/v2-pre/data/types'
interface StarIconProps {
star: StarRank
}
const StarIcon = ({ star }: StarIconProps) => {
return (
<Box
sx={{
width: 30,
height: 30,
background: `no-repeat 50% / 30px url(${getStarIconPath(star)})`
}}
/>
)
}
export default StarIcon
function getStarIconPath(star: StarRank) {
switch (star) {
case 'b':
return `${assetsBucketBaseUrl}/raw/avatarstar/Star_Avatar_1.png`
case 'a':
return `${assetsBucketBaseUrl}/raw/avatarstar/Star_Avatar_2.png`
case 's':
return `${assetsBucketBaseUrl}/raw/avatarstar/Star_Avatar_3.png`
case 'ss':
return `${assetsBucketBaseUrl}/raw/avatarstar/Star_Avatar_4.png`
case 'sss':
return `${assetsBucketBaseUrl}/raw/avatarstar/Star_Avatar_5.png`
case 's1':
return `${assetsBucketBaseUrl}/raw/avatarstarskill/S1.png`
case 's2':
return `${assetsBucketBaseUrl}/raw/avatarstarskill/S2.png`
case 's3':
return `${assetsBucketBaseUrl}/raw/avatarstarskill/S3.png`
case 'ss1':
return `${assetsBucketBaseUrl}/raw/avatarstarskill/SS1.png`
case 'ss2':
return `${assetsBucketBaseUrl}/raw/avatarstarskill/SS2.png`
case 'ss3':
return `${assetsBucketBaseUrl}/raw/avatarstarskill/SS3.png`
}
}
+98
View File
@@ -0,0 +1,98 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
import { TagType } from '../../lib/v2-pre/data/types'
interface TagIconProps {
type: TagType
strength?: 1 | 2 | 3 | 4
size?: 'sm' | 'md'
comment?: string
}
const TagIcon = ({ type, strength, size = 'md', comment }: TagIconProps) => {
const boxSizePx = size === 'md' ? '30px' : '20px'
const iconSizePx = size === 'md' ? '24px' : '16px'
return (
<Box sx={{ position: 'relative' }}>
<Box
sx={{
position: 'absolute',
right: '2px',
top: '-2px',
fontSize: 0,
color: 'white',
textShadow:
'#555 0px 0px 1px, #555 0px 0px 1px, #555 0px 0px 1px,#555 0px 0px 1px, #555 0px 0px 1px, #555 0px 0px 1px'
}}
>
{strength}
</Box>
<Box
title={comment}
sx={{
borderRadius: 5,
width: boxSizePx,
height: boxSizePx,
background: `no-repeat 50% / ${iconSizePx} ${iconSizePx} url(${assetsBucketBaseUrl}/raw/newstateicon/${getTagIconFileName(
type
)}.png), #5a8cb0`
}}
/>
</Box>
)
}
export default TagIcon
function getTagIconFileName(type: TagType) {
switch (type) {
case 'branch':
return 'State_Branch'
case 'charge':
return 'State_Charge'
case 'physical-dmg':
return 'State_Physical'
case 'fire-dmg':
return 'State_Fire'
case 'ice-dmg':
return 'State_Ice'
case 'lightning-dmg':
return 'State_Lightning'
case 'freeze':
return 'State_Frozen'
case 'paralyze':
return 'State_Paralysis'
case 'stun':
return 'State_Stun'
case 'ignite':
return 'State_Burn'
case 'bleed':
return 'State_Bleading'
case 'heavy-atk':
return 'State_Smash'
case 'weaken':
return 'State_Weak'
case 'impair':
return 'State_Fragile'
case 'float':
return 'State_Float'
case 'time-mastery':
return 'State_WitchTime'
case 'gather':
return 'State_SlowDown'
case 'heal':
return 'State_Heal'
case 'fast-atk':
return 'State_HighFreq'
case 'burst':
return 'State_Burst'
case 'shield':
return 'State_Shield'
case 'aerial':
return 'State_AntiAir'
case 'ranged':
return 'State_Remote'
case 'meele':
return 'State_Meele'
}
}
+50
View File
@@ -0,0 +1,50 @@
import { Box } from 'theme-ui'
import { assetsBucketBaseUrl } from '../../lib/consts'
import { WeaponType } from '../../lib/v2-pre/data/types'
interface WeaponTypeIconProps {
type: WeaponType
}
const WeaponTypeIcon = ({ type }: WeaponTypeIconProps) => {
return (
<Box
sx={{
borderRadius: 5,
width: 30,
height: 30,
background: `no-repeat 50% 50% / 24px url(${assetsBucketBaseUrl}/raw/weapontypeicons/${getWeaponIconPath(
type
)}.png), #5a8cb0`
}}
></Box>
)
}
export default WeaponTypeIcon
function getWeaponIconPath(type: WeaponType) {
switch (type) {
case 'pistols':
return 'DoublePistolType'
case 'cannon':
return 'MissileType'
case 'katana':
return 'SwordType'
case 'cross':
return 'CrossType'
case '2-handed':
return 'ClaymoreType'
case 'scythe':
return 'ScytheType'
case 'lance':
return 'LanceType'
case 'fists':
return 'FistType'
case 'bow':
return 'BowType'
case 'chakram':
return 'BladeRingType'
case 'javelin':
return 'JavelinType'
}
}
+40
View File
@@ -0,0 +1,40 @@
import { formatSubSkillInfo } from './formatText'
describe('formatSubSkillInfo', () => {
it('prepends 0 if the calculated value below 0', () => {
const params = {
info: '필살기 꿈 세계 창조는 추가로 SP를 소모해 물리 대미지가 추가 소모 SP*#1[f2]%만큼 증가하고, 크리티컬률이 추가 소모 SP*#2[f2]%만큼 증가한다(최대 추가 소모 SP: 50pt). 해당 효과는 피니쉬가 종료될 때까지 지속되며, 캐릭터가 사망하거나 퇴장 시 해제된다.',
maxLv: 11,
paramBase1: 0.004,
paramBase2: 0.0025,
paramBase3: 0,
paramAdd1: 0.0003,
paramAdd2: 0.00025,
paramAdd3: 0
}
const result = formatSubSkillInfo(params)
expect(result).toBe(
'필살기 꿈 세계 창조는 추가로 SP를 소모해 물리 대미지가 추가 소모 SP*<color=#23B2E3FF>0.70%</color>만큼 증가하고, 크리티컬률이 추가 소모 SP*<color=#23B2E3FF>0.50%</color>만큼 증가한다(최대 추가 소모 SP: 50pt). 해당 효과는 피니쉬가 종료될 때까지 지속되며, 캐릭터가 사망하거나 퇴장 시 해제된다.'
)
})
it('appends 0, based on precision, if the calculated value does not have decimals', () => {
const params = {
info: '전투 중 초기 SP가 #1[f1] 증가하며, 오픈월드에서는 10분에 1회 발동한다.',
maxLv: 11,
paramBase1: 20,
paramBase2: 0,
paramBase3: 0,
paramAdd1: 2,
paramAdd2: 0,
paramAdd3: 0
}
const result = formatSubSkillInfo(params)
expect(result).toBe('전투 중 초기 SP가 <color=#23B2E3FF>40.0</color> 증가하며, 오픈월드에서는 10분에 1회 발동한다.')
})
})
+131
View File
@@ -0,0 +1,131 @@
import { BattlesuitSkill } from './types'
export function formatSkillInfo(skill: BattlesuitSkill) {
let param1 = 0
let param2 = 0
let param3 = 0
if (skill.paramSubId1 !== 0) {
const subSkill = skill.subSkills.find(subSkill => subSkill.id === skill.paramSubId1.toString())
if (subSkill != null) {
param1 = calculateParams(subSkill).param1
}
}
if (skill.paramSubId2 !== 0) {
const subSkill = skill.subSkills.find(subSkill => subSkill.id === skill.paramSubId2.toString())
if (subSkill != null) {
param2 = calculateParams(subSkill).param2
}
}
if (skill.paramSubId3 !== 0) {
const subSkill = skill.subSkills.find(subSkill => subSkill.id === skill.paramSubId1.toString())
if (subSkill != null) {
param1 = calculateParams(subSkill).param3
}
}
const params = {
param1,
param2,
param3
}
return replaceParams(replaceNewLine(skill.info), params)
}
export function formatSubSkillInfo({
info,
maxLv,
paramBase1,
paramBase2,
paramBase3,
paramAdd1,
paramAdd2,
paramAdd3
}: {
info: string
maxLv: number
paramBase1: number
paramBase2: number
paramBase3: number
paramAdd1: number
paramAdd2: number
paramAdd3: number
}) {
return replaceParams(
replaceNewLine(info),
calculateParams({
maxLv,
paramBase1,
paramBase2,
paramBase3,
paramAdd1,
paramAdd2,
paramAdd3
})
)
}
export function replaceNewLine(value: string = '') {
return value.replace(/\\n/g, '\n')
}
function convertToPercentage(value: number) {
return value * 100
}
function trimValue(value: number, precision: number = 1): string {
if (precision === 0) {
return Math.round(value).toString()
}
const rounded = Math.round(value * Math.pow(10, precision)).toString()
return `${rounded.slice(0, -1 * precision) || '0'}.${rounded.slice(-1 * precision)}`
}
function calculateParams(params: {
maxLv: number
paramBase1: number
paramBase2: number
paramBase3: number
paramAdd1: number
paramAdd2: number
paramAdd3: number
}): {
param1: number
param2: number
param3: number
} {
return {
param1: params.paramBase1 + (params.maxLv - 1) * params.paramAdd1,
param2: params.paramBase2 + (params.maxLv - 1) * params.paramAdd2,
param3: params.paramBase3 + (params.maxLv - 1) * params.paramAdd3
}
}
const paramRegex = /#([123])\[f([0-9])\](%)?/
function replaceParams(
text: string,
params: {
param1: number
param2: number
param3: number
}
): string {
const result = paramRegex.exec(text)
if (result == null) {
return text
}
const paramNumber = result[1]
const precision = parseInt(result[2], 10)
const asPercentage = result[3]!!
const value = paramNumber === '1' ? params.param1 : paramNumber === '2' ? params.param2 : params.param3
const valueString = asPercentage
? trimValue(convertToPercentage(value), precision) + '%'
: trimValue(value, precision)
const replacedText = text.replace(paramRegex, '<color=#23B2E3FF>' + valueString + '</color>')
return replaceParams(replacedText, params)
}
+212
View File
@@ -0,0 +1,212 @@
import { AttributeType, CharacterType, SkillType, TagType, WeaponType } from './types'
export function getAttributeLabel(type: AttributeType) {
switch (type) {
case 'bio':
return '생물'
case 'psy':
return '이능'
case 'mech':
return '기계'
case 'qua':
return '양자'
case 'img':
return '허수'
case 'none':
return '무속성'
default:
return `Unknown Attribute Type (${type})`
}
}
export function getWeaponTypeLabel(type: WeaponType) {
switch (type) {
case 'pistols':
return '쌍권총'
case 'katana':
return '태도'
case 'cannon':
return '대포'
case 'cross':
return '십자가'
case '2-handed':
return '대검'
case 'scythe':
return '낫'
case 'lance':
return '랜스'
case 'fists':
return '건틀릿'
case 'bow':
return '활'
case 'chakram':
return '차크람'
case 'javelin':
return '재블린'
default:
return `Unknown Weapon Type (${type})`
}
}
export function getSkillTypeLabel(type: SkillType) {
switch (type) {
case 'leader':
return '리더 스킬'
case 'passive':
return '패시브 스킬'
case 'evasion':
return '회피'
case 'special':
return '기본 공격'
case 'ultimate':
return '필살기'
case 'basic':
return '기본 공격'
case 'sp':
return 'SP 스킬'
case 'none':
return '미분류'
default:
return `Unknown Skill Type (${type})`
}
}
export function getCharacterTypeLabel(type: CharacterType) {
switch (type) {
case 'kiana':
return '키아나'
case 'mei':
return '메이'
case 'bronya':
return '브로냐'
case 'himeko':
return '히메코'
case 'theresa':
return '테레사'
case 'fuhua':
return '후카'
case 'rita':
return '리타'
case 'sakura':
return '사쿠라'
case 'kallen':
return '카렌'
case 'olenyevas':
return '아린 자매'
case 'seele':
return '제레'
case 'durandal':
return '듀란달'
case 'fischl':
return '피슬'
case 'elysia':
return '엘리시아'
case 'mobius':
return '뫼비우스'
case 'raven':
return '레이븐'
case 'carole':
return '캐롤'
case 'pardofelis':
return '파르도펠리스'
case 'aponia':
return '아포니아'
case 'eden':
return '에덴'
case 'griseo':
return '그리세오'
case 'vill-v':
return '빌브이'
case 'sushang':
return '소상'
case 'ai':
return '아이'
case 'susannah':
return '수잔나'
case 'hare':
return '래빗'
case 'prometheus':
return '프로메테우스'
case 'kira':
return '키라'
case 'asuka':
return '아스카'
// Event Only
case 'keqing':
return '각청'
// APHO
case 'apho-mei':
return '후서 메이'
case 'apho-adam':
return '후서 아담'
case 'apho-carol':
return '후서 캐롤'
case 'apho-bronya':
return '후서 브로냐'
case 'apho-timido':
return '후서 티미도'
default:
return `Unknown Character Type (${type})`
}
}
export function getTagTypeLabel(type: TagType) {
switch (type) {
case 'branch':
return '분기'
case 'charge':
return '차지'
case 'physical-dmg':
return '물리'
case 'fire-dmg':
return '화염 대미지'
case 'ice-dmg':
return '빙결 대미지'
case 'lightning-dmg':
return '뇌전 대미지'
case 'freeze':
return '빙결'
case 'paralyze':
return '마비'
case 'stun':
return '기절'
case 'ignite':
return '점화'
case 'bleed':
return '출혈'
case 'heavy-atk':
return '강타'
case 'weaken':
return '허약'
case 'impair':
return '취약'
case 'float':
return '띄움'
case 'slow-down':
return '감속'
case 'time-mastery':
return '시공'
case 'gather':
return '흡인'
case 'heal':
return '치료'
case 'fast-atk':
return '높은 빈도'
case 'aerial':
return '버스트'
case 'burst':
return '실드'
case 'shield':
return '대공'
case 'meele':
return '근접'
case 'ranged':
return '원거리'
default:
return `Unknown Tag Type (${type})`
}
}
+144
View File
@@ -0,0 +1,144 @@
export type StarRank = 'b' | 'a' | 's' | 's1' | 's2' | 's3' | 'ss' | 'ss1' | 'ss2' | 'ss3' | 'sss'
export type AttributeType = 'bio' | 'mech' | 'psy' | 'qua' | 'img' | 'none'
export type WeaponType =
| 'pistols'
| 'cannon'
| 'katana'
| 'cross'
| '2-handed'
| 'scythe'
| 'lance'
| 'fists'
| 'bow'
| 'chakram'
| 'javelin'
export type CharacterType =
| 'kiana'
| 'mei'
| 'bronya'
| 'himeko'
| 'theresa'
| 'fuhua'
| 'rita'
| 'sakura'
| 'kallen'
| 'olenyevas'
| 'seele'
| 'durandal'
| 'fischl'
| 'elysia'
| 'mobius'
| 'raven'
| 'carole'
| 'pardofelis'
| 'aponia'
| 'eden'
| 'griseo'
| 'vill-v'
| 'sushang'
| 'ai'
| 'susannah'
| 'hare'
| 'prometheus'
| 'kira'
| 'asuka'
// Event Only
| 'keqing'
// APHO
| 'apho-mei'
| 'apho-adam'
| 'apho-carol'
| 'apho-bronya'
| 'apho-timido'
export type SkillType = 'leader' | 'passive' | 'evasion' | 'special' | 'ultimate' | 'basic' | 'sp' | 'none'
export type TagType =
| 'branch'
| 'charge'
| 'physical-dmg'
| 'fire-dmg'
| 'ice-dmg'
| 'lightning-dmg'
| 'freeze'
| 'paralyze'
| 'stun'
| 'ignite'
| 'bleed'
| 'heavy-atk'
| 'weaken'
| 'impair'
| 'float'
| 'slow-down'
| 'time-mastery'
| 'gather'
| 'heal'
| 'fast-atk'
| 'aerial'
| 'burst'
| 'shield'
| 'meele'
| 'ranged'
export interface Battlesuit {
id: string
fullName: string
shortName: string
desc: string
firstName: string
lastName: string
enFirstName: string
enLastName: string
isEasterner: boolean
initialStar: StarRank
attributeType: AttributeType
weapon: WeaponType
character: CharacterType
skills: BattlesuitSkill[]
tags: TagType[]
}
export type BattlesuitCatalogItem = Pick<
Battlesuit,
'id' | 'fullName' | 'attributeType' | 'weapon' | 'character' | 'initialStar'
>
export interface SkillTagItem {
type: TagType
strength: 1 | 2 | 3 | 4
comment: string
}
export interface BattlesuitSkill {
id: string
name: string
info: string
icon: string
skillType: SkillType
tags: SkillTagItem[]
paramSubId1: number
paramSubId2: number
paramSubId3: number
subSkills: BattlesuitSubSkill[]
}
export interface BattlesuitSubSkill {
id: string
name: string
info: string
brief: string
icon: string
maxLv: number
tags: SkillTagItem[]
paramBase1: number
paramBase2: number
paramBase3: number
paramAdd1: number
paramAdd2: number
paramAdd3: number
unlockStar: StarRank
toggle: boolean
}
+7
View File
@@ -0,0 +1,7 @@
import { BattlesuitSkill } from './types'
export function sortBattlesuitSkill(a: BattlesuitSkill, b: BattlesuitSkill) {
return reversedSkillTypeOrder.indexOf(b.skillType) - reversedSkillTypeOrder.indexOf(a.skillType)
}
const reversedSkillTypeOrder = ['leader', 'passive', 'evasion', 'special', 'ultimate', 'basic', 'sp', 'none'].reverse()
@@ -0,0 +1,66 @@
import { tokenize } from './tokenize'
describe('tokenize', () => {
it('tokenize', () => {
const rawString =
'기본 공격 또는 분기 공격이 적에게 연소 게이지를 <color=#FEDF4CFF><color=#23B2E3FF>4.0</color>pt 누적한다.</color> 발동 간격: <color=#23B2E3FF>6.0</color>초. 기본 공격과 분기 공격이 가하는 화염 원소 대미지가 <color=#23B2E3FF>30.0%</color> 증가한다.'
const result = tokenize(rawString)
expect(result).toEqual([
{
type: 'text',
text: '기본 공격 또는 분기 공격이 적에게 연소 게이지를 '
},
{
type: 'element',
tagName: 'color',
attributes: {
color: '#FEDF4CFF'
},
children: [
{
type: 'element',
tagName: 'color',
attributes: { color: '#23B2E3FF' },
children: [
{
type: 'text',
text: '4.0'
}
]
},
{
type: 'text',
text: 'pt 누적한다.'
}
]
},
{
type: 'text',
text: ' 발동 간격: '
},
{
type: 'element',
tagName: 'color',
attributes: { color: '#23B2E3FF' },
children: [{ type: 'text', text: '6.0' }]
},
{
type: 'text',
text: '초. 기본 공격과 분기 공격이 가하는 화염 원소 대미지가 '
},
{
type: 'element',
tagName: 'color',
attributes: { color: '#23B2E3FF' },
children: [{ type: 'text', text: '30.0%' }]
},
{
type: 'text',
text: ' 증가한다.'
}
])
})
})
+101
View File
@@ -0,0 +1,101 @@
export type TextToken = {
type: 'text'
text: string
}
export type ElementToken = {
type: 'element'
tagName: string
children: FormattedTextToken[]
attributes: {
[key: string]: any
}
}
export type FormattedTextToken = TextToken | ElementToken
export function tokenize(value: string) {
let tokens: FormattedTextToken[] = []
let currentValue = value.replace(/{{/g, '').replace(/}}/g, '')
while (currentValue.length > 0) {
const openingBracketStartIndex = currentValue.indexOf('<')
if (openingBracketStartIndex < 0) {
tokens.push({ type: 'text', text: currentValue })
eat(openingBracketStartIndex)
break
}
if (openingBracketStartIndex > 0) {
const beforeBracket = currentValue.slice(0, openingBracketStartIndex)
tokens.push({ type: 'text', text: beforeBracket })
eat(openingBracketStartIndex)
continue
}
const openingBracketEndIndex = currentValue.indexOf('>')
if (openingBracketEndIndex < 0) {
tokens.push({ type: 'text', text: currentValue })
eat(currentValue.length)
continue
}
const [tagName, attr] = currentValue.slice(openingBracketStartIndex + 1, openingBracketEndIndex).split('=')
// Child exists try to find end
const nextClosingBracketStartIndex = findClosingBracketStartIndex(currentValue)
const children = tokenize(currentValue.slice(openingBracketEndIndex + 1, nextClosingBracketStartIndex))
tokens.push({
type: 'element',
tagName,
attributes: {
color: attr
},
children
})
const nextClosingBracketEndIndex = currentValue.indexOf('>', nextClosingBracketStartIndex)
eat(nextClosingBracketEndIndex + 1)
}
return tokens
function eat(length: number) {
currentValue = currentValue.slice(length)
}
}
function findClosingBracketStartIndex(value: string): number {
let currentValue = value
let cursor = 0
let depth = 0
let nextOpeningBracketStartIndex = -1
let nextClosingBracketStartIndex = -1
while (cursor < value.length) {
nextOpeningBracketStartIndex = currentValue.indexOf('<', cursor)
nextClosingBracketStartIndex = currentValue.indexOf('</', cursor)
cursor = nextOpeningBracketStartIndex
if (nextClosingBracketStartIndex === -1) {
return value.length
}
if (nextOpeningBracketStartIndex === nextClosingBracketStartIndex) {
depth -= 1
if (depth === 0) {
return nextClosingBracketStartIndex
}
const nextClosingBracketEndIndex = currentValue.indexOf('>', cursor)
cursor = nextClosingBracketEndIndex
continue
}
let nextOpeningBracketCloseIndex = currentValue.indexOf('>', nextOpeningBracketStartIndex)
if (nextOpeningBracketCloseIndex === -1) {
return value.length
}
cursor = nextOpeningBracketCloseIndex
depth += 1
}
throw new Error('Failed to find closing bracket. The value might be malformed?')
}
@@ -0,0 +1,176 @@
import { NextPageContext } from 'next'
import { Box, Card, Flex, Heading } from 'theme-ui'
import AttributeIcon from '../../../components/v2-pre/AttributeIcon'
import AvatarFigureImage from '../../../components/v2-pre/AvatarFigureImage'
import AvatarSkillIcon from '../../../components/v2-pre/AvatarSkillIcon'
import AvatarSubSkillIcon from '../../../components/v2-pre/AvatarSubSkillIcon'
import BattlesuitAvatarIcon from '../../../components/v2-pre/BattlesuitAvatarIcon'
import FormattedText from '../../../components/v2-pre/FormattedText'
import { formatSkillInfo, formatSubSkillInfo } from '../../../lib/v2-pre/data/formatText'
import { loadBattlesuitCatalog, loadBattlesuitData } from '../../../lib/v2-pre/server/loadData'
import { Battlesuit, SkillTagItem } from '../../../lib/v2-pre/data/types'
import { Fragment } from 'react'
import TagIcon from '../../../components/v2-pre/TagIcon'
import {
getAttributeLabel,
getCharacterTypeLabel,
getSkillTypeLabel,
getTagTypeLabel,
getWeaponTypeLabel
} from '../../../lib/v2-pre/data/text'
import StarIcon from '../../../components/v2-pre/StarIcon'
import ChibiIcon from '../../../components/v2-pre/ChibiIcon'
import WeaponTypeIcon from '../../../components/v2-pre/WeaponTypeIcon'
import { sortBattlesuitSkill } from '../../../lib/v2-pre/data/utils'
interface BattlesuitShowPageProps {
battlesuit: Battlesuit
}
const BattlesuitShowPage = ({ battlesuit }: BattlesuitShowPageProps) => {
return (
<Box>
<Heading as="h1">{battlesuit.fullName}</Heading>
<AvatarFigureImage battlesuitId={battlesuit.id} sx={{ height: 400 }} />
<Box mb={3}>
<BattlesuitAvatarIcon battlesuit={battlesuit} />
</Box>
<Card mb={3}>
<Flex sx={{ borderBottom: 'default', p: 1 }}>
<Flex sx={{ alignItems: 'center' }}>
<Box mr={2}>
<StarIcon star={battlesuit.initialStar} />
</Box>
<ChibiIcon id={battlesuit.character} />
<Box ml={1} mr={2}>
{getCharacterTypeLabel(battlesuit.character)}
</Box>
<AttributeIcon attributeType={battlesuit.attributeType} size={30} />
<Box ml={1} mr={2}>
{getAttributeLabel(battlesuit.attributeType)}
</Box>
<WeaponTypeIcon type={battlesuit.weapon} />
<Box ml={1}>{getWeaponTypeLabel(battlesuit.weapon)}</Box>
</Flex>
</Flex>
<Flex sx={{ p: 1 }}>
{battlesuit.tags.map(tag => {
return (
<Flex key={tag} sx={{ mr: 2, alignItems: 'center' }}>
<TagIcon type={tag} />
<Box ml={1}>{getTagTypeLabel(tag)}</Box>
</Flex>
)
})}
</Flex>
</Card>
<Heading as="h2">Skills</Heading>
{battlesuit.skills.sort(sortBattlesuitSkill).map(skill => {
return (
<Card key={skill.id} mb={3}>
<Box sx={{ borderBottom: 'default', p: 1 }}>
<Flex sx={{ alignItems: 'center' }}>
<AvatarSkillIcon icon={skill.icon} />
<Heading as="h3" sx={{ ml: 1, mb: 0 }}>
<FormattedText>{skill.name}</FormattedText>
</Heading>
{skill.tags.length > 0 && (
<Flex sx={{ ml: 1 }}>
{skill.tags.map((tag, index) => {
return <SkillTagItem key={index} tag={tag} />
})}
</Flex>
)}
</Flex>
<Box>{getSkillTypeLabel(skill.skillType)}</Box>
</Box>
<Box sx={{ whiteSpace: 'pre-wrap', borderBottom: 'default', p: 1 }}>
<FormattedText>{formatSkillInfo(skill)}</FormattedText>
</Box>
{skill.subSkills.map(subSkill => {
return (
<Fragment key={subSkill.id}>
<Box sx={{ borderBottom: 'default', p: 1 }}>
<Flex sx={{ alignItems: 'center' }}>
<AvatarSubSkillIcon icon={subSkill.icon} />
<Heading as="h4" sx={{ ml: 1, mb: 0 }}>
<FormattedText>{subSkill.name}</FormattedText>
</Heading>
{subSkill.unlockStar.localeCompare(battlesuit.initialStar) > 0 && (
<Box ml={2}>
<StarIcon star={subSkill.unlockStar} />
</Box>
)}
{subSkill.tags.length > 0 && (
<Flex sx={{ ml: 2 }}>
{subSkill.tags.map((tag, index) => {
return <SkillTagItem key={index} tag={tag} />
})}
</Flex>
)}
</Flex>
</Box>
<Box
sx={{
whiteSpace: 'pre-wrap',
borderBottom: 'default',
'&:last-child': { borderBottom: 'none' },
p: 1
}}
>
<FormattedText>{formatSubSkillInfo(subSkill)}</FormattedText>
</Box>
</Fragment>
)
})}
</Card>
)
})}
<pre>{JSON.stringify(battlesuit, null, 2)}</pre>
</Box>
)
}
export default BattlesuitShowPage
export async function getStaticProps({ locale, params }: NextPageContext & { params: { battlesuitId: string } }) {
const battlesuit = loadBattlesuitData(params.battlesuitId)
return {
props: { battlesuit }
}
}
export async function getStaticPaths() {
const battlesuitCatalog = loadBattlesuitCatalog()
return {
paths: battlesuitCatalog.map(catalogItem => {
return {
params: { battlesuitId: catalogItem.id }
}
}),
fallback: false
}
}
interface SkillTagBoxProps {
tag: SkillTagItem
}
const SkillTagItem = ({ tag }: SkillTagBoxProps) => {
return <TagIcon type={tag.type} strength={tag.strength} comment={tag.comment} size="sm" />
}
+40
View File
@@ -0,0 +1,40 @@
/** @jsxImportSource theme-ui */
import { Box } from '@theme-ui/components'
import { NextPageContext } from 'next'
import { Flex, Link } from 'theme-ui'
import { loadBattlesuitCatalog } from '../../../lib/v2-pre/server/loadData'
import { BattlesuitCatalogItem } from '../../../lib/v2-pre/data/types'
import BattlesuitCatalogItemCard from '../../../components/v2-pre/BattlesuitCatalogItemCard'
interface BattlesuitListPageProps {
battlesuitCatalog: BattlesuitCatalogItem[]
}
const BattlesuitListPage = ({ battlesuitCatalog }: BattlesuitListPageProps) => {
return (
<Box>
<h1>Battlesuits</h1>
<Flex sx={{ flexWrap: 'wrap' }}>
{battlesuitCatalog.map(battlesuit => {
return (
<Box key={battlesuit.id}>
<Link href={`/v2-pre/battlesuits/${battlesuit.id}`}>
<BattlesuitCatalogItemCard battlesuit={battlesuit} />
</Link>
</Box>
)
})}
</Flex>
</Box>
)
}
export default BattlesuitListPage
export async function getStaticProps({ locale }: NextPageContext) {
const battlesuitCatalog = loadBattlesuitCatalog()
return {
props: { battlesuitCatalog }
}
}
+10
View File
@@ -0,0 +1,10 @@
const HomePage = () => {
return (
<div>
<h1>V2 Pre</h1>
<a href="/v2-pre/battlesuits">Battlesuits</a>
</div>
)
}
export default HomePage