diff --git a/src/components/v2-pre/AttributeIcon.tsx b/src/components/v2-pre/AttributeIcon.tsx
new file mode 100644
index 00000000..62202bd1
--- /dev/null
+++ b/src/components/v2-pre/AttributeIcon.tsx
@@ -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 (
+
+ )
+}
+
+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`
+ }
+}
diff --git a/src/components/v2-pre/AvatarFigureImage.tsx b/src/components/v2-pre/AvatarFigureImage.tsx
new file mode 100644
index 00000000..43042eca
--- /dev/null
+++ b/src/components/v2-pre/AvatarFigureImage.tsx
@@ -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
+}
+
+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`
+}
diff --git a/src/components/v2-pre/AvatarSkillIcon.tsx b/src/components/v2-pre/AvatarSkillIcon.tsx
new file mode 100644
index 00000000..0d0d9804
--- /dev/null
+++ b/src/components/v2-pre/AvatarSkillIcon.tsx
@@ -0,0 +1,20 @@
+import { Box } from 'theme-ui'
+import { assetsBucketBaseUrl } from '../../lib/consts'
+
+interface AvatarSkillIconProps {
+ icon: string
+}
+
+const AvatarSkillIcon = ({ icon: fileName }: AvatarSkillIconProps) => {
+ return (
+
+ )
+}
+
+export default AvatarSkillIcon
diff --git a/src/components/v2-pre/AvatarSubSkillIcon.tsx b/src/components/v2-pre/AvatarSubSkillIcon.tsx
new file mode 100644
index 00000000..4f6a474d
--- /dev/null
+++ b/src/components/v2-pre/AvatarSubSkillIcon.tsx
@@ -0,0 +1,20 @@
+import { Box } from 'theme-ui'
+import { assetsBucketBaseUrl } from '../../lib/consts'
+
+interface AvatarSubSkillIconProps {
+ icon: string
+}
+
+const AvatarSubSkillIcon = ({ icon: fileName }: AvatarSubSkillIconProps) => {
+ return (
+
+ )
+}
+
+export default AvatarSubSkillIcon
diff --git a/src/components/v2-pre/BattlesuitAvatarIcon.tsx b/src/components/v2-pre/BattlesuitAvatarIcon.tsx
new file mode 100644
index 00000000..84f97349
--- /dev/null
+++ b/src/components/v2-pre/BattlesuitAvatarIcon.tsx
@@ -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 (
+
+
+
+ )
+}
+
+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`
+ }
+}
diff --git a/src/components/v2-pre/BattlesuitCatalogItemCard.tsx b/src/components/v2-pre/BattlesuitCatalogItemCard.tsx
new file mode 100644
index 00000000..446d1b6f
--- /dev/null
+++ b/src/components/v2-pre/BattlesuitCatalogItemCard.tsx
@@ -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 (
+
+
+
+ {battlesuit.fullName}
+
+
+ )
+}
+
+export default BattlesuitCatalogItemCard
diff --git a/src/components/v2-pre/ChibiIcon.tsx b/src/components/v2-pre/ChibiIcon.tsx
new file mode 100644
index 00000000..68e7330b
--- /dev/null
+++ b/src/components/v2-pre/ChibiIcon.tsx
@@ -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 (
+
+ )
+}
+
+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'
+ }
+}
diff --git a/src/components/v2-pre/FormattedText.tsx b/src/components/v2-pre/FormattedText.tsx
new file mode 100644
index 00000000..b16326d9
--- /dev/null
+++ b/src/components/v2-pre/FormattedText.tsx
@@ -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 (
+
+ {renderTokens(token.children)}
+
+ )
+ }
+
+ 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
+}
diff --git a/src/components/v2-pre/StarIcon.tsx b/src/components/v2-pre/StarIcon.tsx
new file mode 100644
index 00000000..27226240
--- /dev/null
+++ b/src/components/v2-pre/StarIcon.tsx
@@ -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 (
+
+ )
+}
+
+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`
+ }
+}
diff --git a/src/components/v2-pre/TagIcon.tsx b/src/components/v2-pre/TagIcon.tsx
new file mode 100644
index 00000000..1cee10b9
--- /dev/null
+++ b/src/components/v2-pre/TagIcon.tsx
@@ -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 (
+
+
+ {strength}
+
+
+
+ )
+}
+
+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'
+ }
+}
diff --git a/src/components/v2-pre/WeaponTypeIcon.tsx b/src/components/v2-pre/WeaponTypeIcon.tsx
new file mode 100644
index 00000000..b6feb820
--- /dev/null
+++ b/src/components/v2-pre/WeaponTypeIcon.tsx
@@ -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 (
+
+ )
+}
+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'
+ }
+}
diff --git a/src/lib/v2-pre/data/formatText.spec.ts b/src/lib/v2-pre/data/formatText.spec.ts
new file mode 100644
index 00000000..93627482
--- /dev/null
+++ b/src/lib/v2-pre/data/formatText.spec.ts
@@ -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*0.70%만큼 증가하고, 크리티컬률이 추가 소모 SP*0.50%만큼 증가한다(최대 추가 소모 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가 40.0 증가하며, 오픈월드에서는 10분에 1회 발동한다.')
+ })
+})
diff --git a/src/lib/v2-pre/data/formatText.ts b/src/lib/v2-pre/data/formatText.ts
new file mode 100644
index 00000000..4a73780e
--- /dev/null
+++ b/src/lib/v2-pre/data/formatText.ts
@@ -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, '' + valueString + '')
+
+ return replaceParams(replacedText, params)
+}
diff --git a/src/lib/v2-pre/data/text.ts b/src/lib/v2-pre/data/text.ts
new file mode 100644
index 00000000..57d117a8
--- /dev/null
+++ b/src/lib/v2-pre/data/text.ts
@@ -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})`
+ }
+}
diff --git a/src/lib/v2-pre/data/types.ts b/src/lib/v2-pre/data/types.ts
new file mode 100644
index 00000000..10423dc4
--- /dev/null
+++ b/src/lib/v2-pre/data/types.ts
@@ -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
+}
diff --git a/src/lib/v2-pre/data/utils.ts b/src/lib/v2-pre/data/utils.ts
new file mode 100644
index 00000000..ecaad68f
--- /dev/null
+++ b/src/lib/v2-pre/data/utils.ts
@@ -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()
diff --git a/src/lib/v2-pre/formattedText/tokenize.spec.ts b/src/lib/v2-pre/formattedText/tokenize.spec.ts
new file mode 100644
index 00000000..30dc38b8
--- /dev/null
+++ b/src/lib/v2-pre/formattedText/tokenize.spec.ts
@@ -0,0 +1,66 @@
+import { tokenize } from './tokenize'
+
+describe('tokenize', () => {
+ it('tokenize', () => {
+ const rawString =
+ '기본 공격 또는 분기 공격이 적에게 연소 게이지를 4.0pt 누적한다. 발동 간격: 6.0초. 기본 공격과 분기 공격이 가하는 화염 원소 대미지가 30.0% 증가한다.'
+
+ 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: ' 증가한다.'
+ }
+ ])
+ })
+})
diff --git a/src/lib/v2-pre/formattedText/tokenize.ts b/src/lib/v2-pre/formattedText/tokenize.ts
new file mode 100644
index 00000000..d805d91a
--- /dev/null
+++ b/src/lib/v2-pre/formattedText/tokenize.ts
@@ -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?')
+}
diff --git a/src/pages/v2-pre/battlesuits/[battlesuitId].tsx b/src/pages/v2-pre/battlesuits/[battlesuitId].tsx
new file mode 100644
index 00000000..6170b23c
--- /dev/null
+++ b/src/pages/v2-pre/battlesuits/[battlesuitId].tsx
@@ -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 (
+
+ {battlesuit.fullName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getCharacterTypeLabel(battlesuit.character)}
+
+
+
+
+ {getAttributeLabel(battlesuit.attributeType)}
+
+
+
+ {getWeaponTypeLabel(battlesuit.weapon)}
+
+
+
+ {battlesuit.tags.map(tag => {
+ return (
+
+
+ {getTagTypeLabel(tag)}
+
+ )
+ })}
+
+
+
+ Skills
+ {battlesuit.skills.sort(sortBattlesuitSkill).map(skill => {
+ return (
+
+
+
+
+
+ {skill.name}
+
+ {skill.tags.length > 0 && (
+
+ {skill.tags.map((tag, index) => {
+ return
+ })}
+
+ )}
+
+
+ {getSkillTypeLabel(skill.skillType)}
+
+
+ {formatSkillInfo(skill)}
+
+
+ {skill.subSkills.map(subSkill => {
+ return (
+
+
+
+
+
+ {subSkill.name}
+
+
+ {subSkill.unlockStar.localeCompare(battlesuit.initialStar) > 0 && (
+
+
+
+ )}
+
+ {subSkill.tags.length > 0 && (
+
+ {subSkill.tags.map((tag, index) => {
+ return
+ })}
+
+ )}
+
+
+
+
+ {formatSubSkillInfo(subSkill)}
+
+
+ )
+ })}
+
+ )
+ })}
+ {JSON.stringify(battlesuit, null, 2)}
+
+ )
+}
+
+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
+}
diff --git a/src/pages/v2-pre/battlesuits/index.tsx b/src/pages/v2-pre/battlesuits/index.tsx
new file mode 100644
index 00000000..ec8dc60a
--- /dev/null
+++ b/src/pages/v2-pre/battlesuits/index.tsx
@@ -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 (
+
+ Battlesuits
+
+ {battlesuitCatalog.map(battlesuit => {
+ return (
+
+
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+export default BattlesuitListPage
+
+export async function getStaticProps({ locale }: NextPageContext) {
+ const battlesuitCatalog = loadBattlesuitCatalog()
+
+ return {
+ props: { battlesuitCatalog }
+ }
+}
diff --git a/src/pages/v2-pre/index.tsx b/src/pages/v2-pre/index.tsx
new file mode 100644
index 00000000..7cd1f3ad
--- /dev/null
+++ b/src/pages/v2-pre/index.tsx
@@ -0,0 +1,10 @@
+const HomePage = () => {
+ return (
+
+ )
+}
+
+export default HomePage