const SPRITE_ART_URL = "url('assets/roster-poses.png')"; const CARD_ART_ROOT = "assets/cards"; const CHARACTER_COUNT = 1000; const DAMAGE_ART_STAGES = 5; const SPRITE_COLUMNS = 5; const SPRITE_ROWS = 4; const ELEMENTS = { water: { key: "water", name: "수", hanja: "水", beats: "fire", color: "var(--water)" }, metal: { key: "metal", name: "금", hanja: "金", beats: "wood", color: "var(--metal)" }, earth: { key: "earth", name: "지", hanja: "地", beats: "water", color: "var(--earth)" }, fire: { key: "fire", name: "화", hanja: "火", beats: "metal", color: "var(--fire)" }, wood: { key: "wood", name: "목", hanja: "木", beats: "earth", color: "var(--wood)" }, }; const ELEMENT_KEYS = Object.keys(ELEMENTS); const STAR_TIERS = [ { stars: 1, weight: 42, hp: [74, 88], atk: [12, 17], def: [4, 7] }, { stars: 2, weight: 28, hp: [88, 108], atk: [16, 22], def: [6, 10] }, { stars: 3, weight: 17, hp: [106, 130], atk: [21, 29], def: [9, 13] }, { stars: 4, weight: 9, hp: [124, 154], atk: [27, 36], def: [12, 17] }, { stars: 5, weight: 4, hp: [146, 182], atk: [34, 45], def: [16, 22] }, ]; const RACE_PROFILES = [ { name: "동양인 인간", weight: 54, nameMode: "korean", bias: { hp: 1, atk: 1, def: 1 } }, { name: "서양인 인간", weight: 8, nameMode: "western", bias: { hp: 1.02, atk: 1, def: 1 } }, { name: "흑인 인간", weight: 8, nameMode: "diaspora", bias: { hp: 1.04, atk: 1.02, def: 0.98 } }, { name: "남아시아 인간", weight: 5, nameMode: "southern", bias: { hp: 0.98, atk: 1.04, def: 1 } }, { name: "라틴계 인간", weight: 5, nameMode: "western", bias: { hp: 1, atk: 1.03, def: 0.99 } }, { name: "엘프", weight: 7, nameMode: "elven", bias: { hp: 0.94, atk: 1.08, def: 0.98 } }, { name: "하프엘프", weight: 5, nameMode: "elven", bias: { hp: 0.98, atk: 1.05, def: 1 } }, { name: "드워프", weight: 5, nameMode: "dwarven", bias: { hp: 1.12, atk: 0.95, def: 1.1 } }, { name: "용혈족", weight: 2, nameMode: "fantasy", bias: { hp: 1.08, atk: 1.08, def: 0.98 } }, { name: "정령 혼혈", weight: 1, nameMode: "fantasy", bias: { hp: 0.95, atk: 1.1, def: 1.02 } }, ]; const BODY_TYPES = [ { name: "균형형", bias: { hp: 1, atk: 1, def: 1 } }, { name: "장신형", bias: { hp: 1.03, atk: 1.03, def: 0.98 } }, { name: "근력형", bias: { hp: 1.08, atk: 1.06, def: 0.98 } }, { name: "민첩형", bias: { hp: 0.96, atk: 1.08, def: 0.96 } }, { name: "중갑형", bias: { hp: 1.08, atk: 0.94, def: 1.12 } }, { name: "소형 근력형", bias: { hp: 1.06, atk: 1.02, def: 1.08 } }, { name: "유연형", bias: { hp: 0.98, atk: 1.05, def: 1 } }, { name: "지구력형", bias: { hp: 1.12, atk: 0.96, def: 1.04 } }, { name: "빠른 발놀림형", bias: { hp: 0.95, atk: 1.1, def: 0.96 } }, { name: "단단한 체형", bias: { hp: 1.1, atk: 0.96, def: 1.1 } }, { name: "긴 팔다리형", bias: { hp: 0.98, atk: 1.07, def: 0.98 } }, { name: "묵직한 파워형", bias: { hp: 1.14, atk: 1.04, def: 0.96 } }, ]; const WEAPONS = [ { name: "한손검", role: "검무사", bias: { hp: 1, atk: 1.04, def: 1 } }, { name: "쌍검", role: "쌍검사", bias: { hp: 0.96, atk: 1.1, def: 0.96 } }, { name: "장창", role: "창술가", bias: { hp: 1.02, atk: 1.06, def: 0.98 } }, { name: "대검", role: "참격가", bias: { hp: 1.04, atk: 1.1, def: 0.94 } }, { name: "활", role: "궁수", bias: { hp: 0.94, atk: 1.08, def: 0.96 } }, { name: "석궁", role: "저격수", bias: { hp: 0.96, atk: 1.09, def: 0.98 } }, { name: "전투 부채", role: "선술사", bias: { hp: 0.98, atk: 1.03, def: 1.03 } }, { name: "철선", role: "철선사", bias: { hp: 1, atk: 1.02, def: 1.04 } }, { name: "전투 망치", role: "쇄격수", bias: { hp: 1.1, atk: 1.07, def: 0.96 } }, { name: "방패", role: "수호자", bias: { hp: 1.12, atk: 0.92, def: 1.14 } }, { name: "장봉", role: "봉술가", bias: { hp: 1.02, atk: 1, def: 1.04 } }, { name: "주술 지팡이", role: "주술사", bias: { hp: 0.94, atk: 1.11, def: 0.98 } }, { name: "단검", role: "기습가", bias: { hp: 0.92, atk: 1.12, def: 0.94 } }, { name: "사슬낫", role: "쇄겸사", bias: { hp: 0.98, atk: 1.07, def: 1 } }, { name: "권갑", role: "권투사", bias: { hp: 1.08, atk: 1.04, def: 1 } }, { name: "비녀검", role: "암검사", bias: { hp: 0.95, atk: 1.1, def: 0.98 } }, { name: "도끼", role: "파쇄자", bias: { hp: 1.08, atk: 1.1, def: 0.94 } }, { name: "약병", role: "연금술사", bias: { hp: 0.98, atk: 1.02, def: 1.04 } }, { name: "부적", role: "결계사", bias: { hp: 1, atk: 0.98, def: 1.1 } }, { name: "월도", role: "월도병", bias: { hp: 1.04, atk: 1.06, def: 0.99 } }, ]; const WORLD_ARCHETYPES = [ { world: "현대 한국 캠퍼스 이능 결투", era: "현시대 대학가", affinity: "fire", role: "화염술 대학생", weapon: "불꽃을 쏘는 스마트폰 촉매와 강의노트", origin: "서울 사립대 마법동아리 출신", pose: "후드 집업을 휘날리며 손끝에서 불꽃을 쏘는 자세", scene: "night university campus plaza, lecture buildings, vending machines, burning magic circles, fire elemental atmosphere", costume: "modern adult university outfit with cropped tactical jacket, pleated skirt or denim, sneakers, charm accessories, hidden protective pads", style: "modern urban fantasy trading card key art, cinematic campus lighting, contemporary Korean character design", damageProfile: "재킷 소매, 가방끈, 운동화 보호대, 허리 장식이 전투 중 찢어지고 그을린다", bias: { hp: 0.98, atk: 1.12, def: 0.94 }, }, { world: "K-pop 수상 무대 이능 배틀", era: "현시대 엔터테인먼트 무대", affinity: "water", role: "수류 아이돌", weapon: "물의 리본 마이크와 무대 드론", origin: "대형 기획사 비밀 유닛 출신", pose: "마이크를 든 채 파도 리본을 회전시키는 퍼포먼스 자세", scene: "concert stage with LED screens, rain rig, spotlights, water ribbons, backstage haze, water elemental atmosphere", costume: "adult idol stage outfit with jeweled performance jacket, fitted bodice, thigh-high boots, headset mic, waterproof accessories", style: "glossy music-video fantasy trading card art, stage lights, water choreography, premium idol styling", damageProfile: "무대 재킷, 부츠 장식, 마이크 리본, 반짝이는 장신구가 젖고 찢어진다", bias: { hp: 0.94, atk: 1.08, def: 0.98 }, }, { world: "네온 사이버펑크 거리전", era: "근미래 메가시티", affinity: "metal", role: "메탈 해커", weapon: "자성 나노드론과 손목 해킹덱", origin: "지하 데이터 시장 출신", pose: "한 손으로 홀로그램을 조작하고 다른 손으로 금속 드론을 날리는 자세", scene: "rainy neon alley, hologram billboards, magnetic drones, chrome sparks, metal elemental atmosphere", costume: "sleek cyber jacket, reflective bodysuit panels, utility belt, visor, exposed cable ornaments", style: "cyberpunk TCG key art, neon reflections, chrome detail, high contrast rain lighting", damageProfile: "전술 재킷, 해킹덱 스트랩, 바이저, 크롬 보호판이 깨지고 스파크가 튄다", bias: { hp: 0.96, atk: 1.1, def: 1 }, }, { world: "우주 정거장 무중력 결투", era: "스페이스 오페라", affinity: "earth", role: "중력 조율사", weapon: "중력 앵커 부츠와 궤도 링", origin: "달 궤도 정비국 출신", pose: "무중력에서 몸을 비틀며 중력장을 접는 자세", scene: "space station observation deck, Earth below, floating debris, gravity lensing, earth elemental atmosphere", costume: "fitted space suit with armored collar, utility harness, magnetic boots, translucent pressure cape", style: "space opera fantasy card art, cinematic starfield, polished sci-fi armor, orbital lighting", damageProfile: "우주복 외피, 어깨 링, 자력 부츠, 산소팩 보호판이 찢기고 깨진다", bias: { hp: 1.04, atk: 1.02, def: 1.06 }, }, { world: "포스트 아포칼립스 황무지 레이스", era: "붕괴 이후 근미래", affinity: "fire", role: "폭염 바이크 레이서", weapon: "연소 엔진 랜스와 점화 장갑", origin: "사막 고속도로 연맹 출신", pose: "바이크 옆에서 점화 장갑을 켜고 돌진을 준비하는 자세", scene: "dusty desert highway, wrecked vehicles, orange sunset, engine flames, fire elemental atmosphere", costume: "racing leather, armored crop jacket, reinforced boots, torn scarf, heat-resistant plates", style: "post-apocalyptic action card art, dust, sparks, speed lines, hard sunlight", damageProfile: "라이더 재킷, 무릎 보호대, 부츠, 목 스카프가 타고 찢어진다", bias: { hp: 1.02, atk: 1.12, def: 0.96 }, }, { world: "현대 병원 퇴마 응급실", era: "현시대 메디컬 오컬트", affinity: "wood", role: "생명술 외과의", weapon: "치유 나이프와 생체 덩굴 실", origin: "야간 응급센터 출신", pose: "수술 장갑 낀 손으로 생체 덩굴 실을 펼치는 자세", scene: "emergency room corridor, monitors, ritual plants, surgical lights, wood elemental atmosphere", costume: "fitted medical coat, tactical corset harness, gloves, ID lanyard, protective boots", style: "medical occult fantasy card art, sterile lights mixed with organic magic, dramatic realism", damageProfile: "의료 코트, 장갑, 보호 앞치마, 허리 하네스가 찢어지고 오염된다", bias: { hp: 1.08, atk: 0.98, def: 1.04 }, }, { world: "스팀펑크 공중도시", era: "증기기관 대체역사", affinity: "metal", role: "공중정비 기사", weapon: "기어 해머와 증기 윙팩", origin: "부유 항구 정비조합 출신", pose: "윙팩을 펼치고 기어 해머를 어깨에 얹은 자세", scene: "floating steampunk harbor, airships, brass gears, steam clouds, metal elemental atmosphere", costume: "brass corset armor, mechanic gloves, goggles, layered work skirt, wingpack harness", style: "steampunk collectible card art, brass machinery, warm steam, ornate engineering detail", damageProfile: "황동 코르셋, 고글, 장갑, 윙팩 하네스와 치마 레이어가 파손된다", bias: { hp: 1.04, atk: 1.05, def: 1.04 }, }, { world: "느와르 탐정 도시", era: "1940년대풍 마법 느와르", affinity: "water", role: "비의 탐정", weapon: "우산 지팡이와 빗물 탄환", origin: "항만 사립탐정 사무소 출신", pose: "우산을 기울인 채 빗물 탄환을 모으는 측면 자세", scene: "rainy noir street, streetlamp, old cars, puddles, cigarette smoke, water elemental atmosphere", costume: "fitted trench coat, pencil skirt, gloves, rain boots, detective hat with occult charm", style: "noir fantasy TCG art, moody rain, rim-lit silhouette, elegant retro styling", damageProfile: "트렌치코트, 장갑, 모자 장식, 우산 천이 찢어지고 젖는다", bias: { hp: 0.98, atk: 1.04, def: 1.04 }, }, { world: "현대 스포츠 스타 이능 리그", era: "현시대 프로 스포츠", affinity: "earth", role: "암석 배구 에이스", weapon: "암석 스파이크 글러브와 코트 라인 결계", origin: "프로 배구 이능 리그 출신", pose: "점프 스파이크 직전 공중에서 암석장을 만드는 자세", scene: "indoor sports arena, court lights, cheering screens, floating stone fragments, earth elemental atmosphere", costume: "adult pro athlete uniform, compression gear, knee pads, team jacket, reinforced sneakers", style: "dynamic sports anime trading card art, arena lighting, motion blur, collectible finish", damageProfile: "팀 재킷, 무릎 보호대, 압박복, 운동화와 팔 보호대가 찢어진다", bias: { hp: 1.08, atk: 1.06, def: 0.98 }, }, { world: "심해 왕국 탐사전", era: "해저 판타지 SF", affinity: "water", role: "심해 탐사공주", weapon: "수압 창과 산호 드론", origin: "심해 연구 왕정 출신", pose: "수중에서 창을 뒤로 당기며 물살을 가르는 자세", scene: "underwater palace ruins, bioluminescent coral, bubbles, pressure waves, water elemental atmosphere", costume: "sleek aquatic armor, translucent fins, pearl harness, waterproof bodice, coral ornaments", style: "underwater fantasy card art, bioluminescence, floating hair, soft blue light", damageProfile: "수중 갑피, 지느러미 장식, 산호 하네스와 진주 끈이 깨지고 흩어진다", bias: { hp: 1, atk: 1.06, def: 1.02 }, }, { world: "시간감옥 크로노 판타지", era: "시간선 밖의 결투장", affinity: "metal", role: "시계장치 사도", weapon: "초침 레이피어와 시간 톱니", origin: "멈춘 시계탑 출신", pose: "공중의 톱니를 밟고 초침 검을 찌르는 자세", scene: "fractured clock tower outside time, floating gears, frozen glass shards, metal elemental atmosphere", costume: "clockwork bodice armor, capelet, thigh boots, gear halo, ornate gloves", style: "chrono fantasy TCG key art, suspended motion, gold and silver gears, surreal lighting", damageProfile: "시계장치 흉갑, 장갑, 망토, 톱니 장식이 부서지고 시간 파편이 흩어진다", bias: { hp: 0.98, atk: 1.08, def: 1.02 }, }, { world: "현대 셰프 길드 화염전", era: "현시대 미식 배틀", affinity: "fire", role: "화염 셰프", weapon: "불꽃 웍과 식칼 부적", origin: "심야 푸드트럭 결투장 출신", pose: "웍에서 불기둥을 뽑아내며 한 발을 내딛는 자세", scene: "night food market, neon signs, steam, flying spices, fire elemental atmosphere", costume: "stylish chef jacket, waist apron, fitted pants, heatproof gloves, metal accessories", style: "gourmet battle fantasy card art, steam and flame, vivid street-food lighting", damageProfile: "셰프 재킷, 앞치마, 장갑, 허리 벨트가 그을리고 찢어진다", bias: { hp: 1, atk: 1.1, def: 0.98 }, }, { world: "북극 연구기지 정령전", era: "현대 극지 연구", affinity: "water", role: "빙하 연구원", weapon: "얼음 코어 드릴과 서리 결계", origin: "남극 심층빙 연구소 출신", pose: "드릴을 바닥에 꽂고 빙결장을 펼치는 자세", scene: "polar research base, aurora, ice core machines, snowstorm, water elemental atmosphere", costume: "thermal research suit, fur-trim hood, harness, insulated boots, cracked goggles", style: "arctic science fantasy card art, aurora lighting, snow particles, crisp detail", damageProfile: "방한복, 고글, 하네스, 부츠, 장갑이 찢어지고 얼음 조각이 박힌다", bias: { hp: 1.1, atk: 1, def: 1.04 }, }, { world: "화성 개척지 광산전", era: "근미래 화성 식민지", affinity: "earth", role: "화성 지질술사", weapon: "레일 픽액스와 중력 곡괭이", origin: "마스 돔 광산 7구역 출신", pose: "붉은 암석을 끌어올리며 곡괭이를 내리치는 자세", scene: "Mars colony mine, red dust, habitat domes, low gravity rocks, earth elemental atmosphere", costume: "dusty pressure-mining suit, armored suspenders, visor, utility thigh packs, heavy boots", style: "Mars frontier sci-fi fantasy card art, red dust, industrial lights, rugged detail", damageProfile: "광산복, 바이저, 중력 곡괭이 스트랩, 부츠 보호대가 찢기고 깨진다", bias: { hp: 1.12, atk: 1.04, def: 1 }, }, { world: "고대 신전 런웨이 의식", era: "신화와 패션이 섞인 초현실 세계", affinity: "wood", role: "성목 런웨이 프리스트", weapon: "꽃가지 왕홀과 활주로 결계", origin: "세계수 패션 사원 출신", pose: "런웨이 워킹 중 꽃가지 왕홀로 결계를 펼치는 자세", scene: "ancient temple runway, giant tree roots, floating petals, golden spotlights, wood elemental atmosphere", costume: "high-fashion ritual dress, botanical armor pieces, vine heels, jeweled shoulder ornaments", style: "mythic fashion fantasy card art, editorial runway pose, ornate botanical detail", damageProfile: "의식 드레스, 식물 갑주, 어깨 장식, 힐 스트랩이 찢어지고 꽃잎이 흩어진다", bias: { hp: 0.96, atk: 1.06, def: 1.04 }, }, { world: "서부 개척지 마법 총격전", era: "와일드 웨스트 대체역사", affinity: "fire", role: "태양탄 보안관", weapon: "태양 룬 리볼버와 불꽃 올가미", origin: "사막 철도 마을 출신", pose: "코트를 젖히고 리볼버를 뽑는 결투 자세", scene: "dusty western town, railroad, saloon lights, solar fire runes, fire elemental atmosphere", costume: "western corset vest, long coat, leather belt, boots, sheriff badge, brimmed hat", style: "western fantasy trading card art, dust and sun flare, cinematic duel framing", damageProfile: "롱코트, 모자, 벨트, 부츠, 조끼 단추가 찢기고 탄 자국이 난다", bias: { hp: 0.98, atk: 1.12, def: 0.98 }, }, { world: "가상현실 e스포츠 아레나", era: "근미래 VR 리그", affinity: "metal", role: "VR 검투 스트리머", weapon: "빛의 컨트롤러 블레이드와 데이터 방패", origin: "프로 VR 아레나 출신", pose: "컨트롤러 블레이드를 교차하며 관중석을 향해 도발하는 자세", scene: "virtual esports arena, pixel particles, giant screens, data shields, metal elemental atmosphere", costume: "streamer performance suit, LED jacket, headset, fingerless gloves, hologram belt", style: "neon esports fantasy card art, digital particles, energetic streaming-stage composition", damageProfile: "LED 재킷, 헤드셋, 장갑, 홀로그램 벨트와 데이터 방패가 깨진다", bias: { hp: 0.94, atk: 1.1, def: 1 }, }, { world: "전통 시장 도깨비 야간전", era: "현대 민속 판타지", affinity: "wood", role: "부적 상인", weapon: "나무 부적 타래와 접이식 장대", origin: "심야 전통시장 노점 출신", pose: "부적 타래를 펼치며 좁은 골목을 막아서는 자세", scene: "night traditional market, paper lanterns, wooden talismans, alley mist, wood elemental atmosphere", costume: "modern hanbok jacket mixed with streetwear, waist pouch, boots, talisman ornaments", style: "Korean urban folklore card art, lantern light, street texture, magical realism", damageProfile: "개량 한복 재킷, 부적 끈, 허리 주머니, 부츠 장식이 찢어지고 흩어진다", bias: { hp: 1, atk: 1.02, def: 1.08 }, }, { world: "럭셔리 카지노 운명전", era: "현대 오컬트 카지노", affinity: "metal", role: "운명 딜러", weapon: "금속 카드덱과 룰렛 링", origin: "해안 카지노 VIP룸 출신", pose: "카드를 부채꼴로 펼치며 룰렛 링을 띄우는 자세", scene: "luxury casino floor, roulette lights, gold chips, mirrored ceiling, metal elemental atmosphere", costume: "elegant dealer suit, fitted vest, high heels, metallic card garters, gloves", style: "luxury occult casino card art, gold reflections, dramatic spotlight, sleek glamour", damageProfile: "딜러 조끼, 장갑, 카드 홀더, 하이힐 스트랩과 금속 장식이 파손된다", bias: { hp: 0.96, atk: 1.06, def: 1.04 }, }, { world: "정글 유적 드론 탐사", era: "현대 고고학 어드벤처", affinity: "earth", role: "유적 드론 고고학자", weapon: "석판 드론과 진동 채찍", origin: "열대 유적 탐사팀 출신", pose: "석판 드론을 띄우고 채찍으로 바위를 가르는 자세", scene: "jungle ruins, vines, ancient stones, scanning drones, earth elemental atmosphere", costume: "adventurer crop jacket, utility shorts or pants, boots, satchel, protective straps", style: "adventure fantasy trading card art, jungle light shafts, ancient stone detail", damageProfile: "탐사 재킷, 가방끈, 부츠, 보호 스트랩과 장갑이 찢어진다", bias: { hp: 1.04, atk: 1.06, def: 1 }, }, ]; const PERSONALITIES = [ "냉정한 전략가", "낙천적인 돌파형", "과묵한 수호자", "장난기 많은 승부사", "규율을 중시하는 기사", "직감으로 움직이는 추격자", "동료를 먼저 챙기는 보호자", "승부욕이 강한 도전자", "위험을 계산하는 현실주의자", "화려함을 즐기는 무대형", "상처를 숨기는 인내형", "명예를 중시하는 결투가", "호기심 많은 탐험가", "말보다 행동이 빠른 실전파", "느긋하지만 집요한 추적자", "완벽을 추구하는 장인", "오래 참다 폭발하는 분노형", "조용히 판을 뒤집는 책략가", "가벼운 농담으로 긴장을 푸는 해결사", "약자를 그냥 지나치지 못하는 의협형", "혼자 싸우는 데 익숙한 방랑자", "작은 약속도 지키는 신뢰형", "패배에서 배우는 성장형", "상대의 움직임을 읽는 관찰자", "결정적인 순간에 대담한 승부사", ]; const POSES = [ "검을 비스듬히 세운 방어 자세", "창을 앞으로 찌르는 돌격 자세", "활시위를 끝까지 당긴 조준 자세", "방패를 낮게 붙인 버티기 자세", "두 손으로 주문진을 펼친 시전 자세", "망치를 어깨에 얹은 압박 자세", "쌍검을 교차한 반격 자세", "무릎을 낮춘 회피 준비 자세", "전투 부채를 펼친 유도 자세", "도끼를 뒤로 당긴 파쇄 자세", "지팡이 끝에 빛을 모으는 집중 자세", "단검을 숨긴 기습 자세", "권갑을 맞댄 근접전 자세", "사슬낫을 원형으로 돌리는 견제 자세", "부적을 앞세운 결계 자세", "월도를 크게 휘두르기 직전의 자세", "약병을 던지려는 연금 자세", "칼끝을 바닥에 둔 도발 자세", "뒤로 한 걸음 물러선 역습 자세", "한쪽 어깨를 내민 측면 경계 자세", "머리 위로 검을 든 마무리 자세", "손바닥으로 기운을 밀어내는 제압 자세", "몸을 회전시키는 무희식 참격 자세", "중심을 낮춘 산악 전사 자세", "시선을 돌리지 않는 지휘관 자세", "날아드는 공격을 흘리는 유연 자세", "허리춤 무기를 뽑기 직전의 발도 자세", "상체를 틀어 급소를 감춘 방어 자세", "발끝으로 간격을 재는 탐색 자세", "외투를 휘둘러 시야를 가리는 교란 자세", ]; const ORIGINS = [ "항구 도시 출신", "산악 요새 출신", "왕실 근위대 출신", "떠돌이 용병단 출신", "숲의 사원 출신", "사막 교역로 출신", "북방 대장간 출신", "강가 마을 출신", "비밀 학파 출신", "결투장 챔피언 출신", "몰락 귀족가 출신", "거리 정보상 출신", "해상 경비대 출신", "폐허 탐사대 출신", "약초 길드 출신", "성벽 수비대 출신", "상단 호위대 출신", "서리 산맥 출신", "달빛 극단 출신", "흑요석 광산 출신", ]; const DAMAGE_PROFILES = [ "망토와 견갑이 먼저 찢어진다", "흉갑 장식과 허리 보호구가 부서진다", "소매 보호대와 무릎 갑주가 갈라진다", "외투 끝자락과 어깨 장식이 손상된다", "방패 문장과 가죽끈이 끊어진다", "갑옷 표면에 균열과 그을림이 남는다", "두꺼운 천 장식과 장갑이 해진다", "허리 장식과 전투 망토가 떨어져 나간다", "팔 보호구와 옆구리 장갑판이 흔들린다", "장식 술과 가슴 보호대가 파손된다", ]; const FEMALE_APPEAL_FOCUS = [ "큰 가슴이 부각되는 곡선형 흉갑과 허리선이 강조된 판타지 갑주", "볼륨감 있는 흉부 실루엣과 장식성 코르셋형 전투복", "풍성한 상체 실루엣과 어깨·허리 대비가 또렷한 카드 핀업 구도", "가슴 라인을 강조하는 금속 흉갑과 긴 망토가 어우러진 성인 판타지 의상", "볼륨감 있는 체형을 살리는 전투 드레스와 보호구 조합", "상체 곡선이 돋보이는 실루엣 중심의 고급 갑주 디자인", "성숙한 매력을 살리는 타이트한 전투복과 장식 갑주", "큰 가슴과 균형 잡힌 전신 비율이 카드 아트의 중심이 되는 구도", ]; const FACE_SHAPES = [ "둥근 얼굴형", "긴 타원형 얼굴", "각진 턱선", "작은 하트형 얼굴", "광대가 또렷한 얼굴", "날렵한 V라인 얼굴", "넓은 이마와 부드러운 턱선", "짧고 단단한 턱선", "성숙한 계란형 얼굴", "강한 턱과 높은 광대", ]; const EYE_STYLES = [ "가늘고 날카로운 눈매", "크고 둥근 눈", "처진 눈꼬리의 부드러운 눈매", "치켜올라간 고양이상 눈매", "속쌍꺼풀의 차분한 눈", "깊게 들어간 음영 있는 눈", "넓게 벌어진 맑은 눈", "눈꼬리가 긴 여우상 눈매", "두꺼운 눈썹과 강한 시선", "졸린 듯 반쯤 감긴 눈", ]; const NOSE_STYLES = [ "작고 둥근 코", "높고 곧은 콧대", "짧은 코와 작은 콧망울", "살짝 매부리 느낌의 코", "넓은 콧망울", "긴 콧대와 또렷한 코끝", "부드러운 낮은 콧대", "섬세하고 뾰족한 코끝", ]; const LIP_STYLES = [ "얇고 단정한 입술", "두툼하고 선명한 입술", "작고 앙다문 입", "넓고 자신감 있는 입매", "아랫입술이 도톰한 입", "입꼬리가 살짝 올라간 입", "긴장하면 떨리는 작은 입술", "강한 색감의 도톰한 입술", ]; const SKIN_TONES = [ "밝은 상아빛 피부", "따뜻한 베이지 피부", "햇볕에 그을린 구릿빛 피부", "깊은 갈색 피부", "올리브 톤 피부", "붉은 기가 도는 건강한 피부", "창백한 달빛 피부", "짙은 황갈색 피부", ]; const HAIR_STYLES = [ "검은 긴 생머리와 높은 반묶음", "짧은 단발과 삐죽한 앞머리", "물결치는 긴 웨이브", "높게 묶은 전투 포니테일", "양쪽으로 땋은 굵은 머리", "비대칭 숏컷", "허리까지 오는 곱슬머리", "은빛 단발과 긴 옆머리", "짙은 갈색 땋은 번 헤어", "붉은 기 도는 긴 머리와 흐트러진 앞머리", "푸른빛 검은 머리와 낮은 묶음", "짧은 곱슬머리와 넓은 이마", ]; const BODY_DETAILS = [ "작지만 단단한 근육과 짧은 팔다리", "긴 팔다리와 모델 같은 비율", "넓은 골반과 강한 허벅지", "좁은 어깨와 유연한 허리선", "넓은 어깨와 운동선수 같은 등", "부드러운 곡선형 체형", "마른 근육과 선명한 쇄골", "묵직한 하체와 낮은 무게중심", "큰 키와 긴 목선", "작은 키에 폭발적인 근력", "두꺼운 팔과 단단한 복부", "유연한 무희 같은 체형", ]; const BUST_SHAPES = [ "크고 둥근 흉부 실루엣", "높게 받쳐진 풍성한 흉부 라인", "넓은 흉갑 안에 눌린 볼륨감", "좌우로 넓은 볼륨의 성숙한 흉부", "탄탄하고 무거운 흉부 실루엣", "갑주 장식으로 위쪽 볼륨이 강조된 형태", "허리 대비가 강한 풍만한 상체", "망토와 흉갑 사이로 강조되는 큰 가슴 라인", ]; const PRESENCE_STYLES = [ "차갑고 귀족적인 인상", "친근하지만 위험한 미소", "거칠고 야성적인 분위기", "엄격한 군인 같은 태도", "나른하고 도발적인 분위기", "순진해 보이지만 집요한 눈빛", "성숙하고 지친 전장 베테랑의 인상", "화려하고 자기과시적인 무대 장악력", "조용히 위협적인 암살자 인상", "대담하고 직선적인 전사 분위기", ]; const TITLE_PREFIXES = [ "새벽", "월광", "흑요", "청류", "백야", "홍련", "비취", "은하", "적운", "금사", "설화", "운철", "자수정", "황혼", "벽력", "유리", "녹음", "진홍", "검은", "푸른", "고요한", "불굴의", "날카로운", "깊은", "찬란한", ]; const TITLE_NOUNS = [ "검", "창", "심장", "깃발", "성좌", "결계", "불꽃", "파도", "방패", "연꽃", "철권", "그림자", "나침반", "별무리", "가시", "비늘", "망치", "부적", "월도", "화살", "계약", "장인", "심판", "수문장", "무희", ]; const KOREAN_SURNAMES = [ "한", "윤", "서", "강", "백", "신", "문", "오", "차", "류", "남", "도", "임", "진", "하", "유", "권", "장", "홍", "민", "송", "연", "고", "마", "주", "최", "정", "이", "김", "박", ]; const GIVEN_A = [ "수", "채", "서", "미", "아", "나", "라", "연", "예", "하", "유", "린", "보", "지", "설", "가", "다", "해", "이", "은", "소", "비", "태", "준", "현", "도", "시", "온", "율", "별", ]; const GIVEN_B = [ "아", "린", "연", "희", "라", "진", "영", "율", "별", "현", "서", "윤", "원", "나", "빈", "하", "솔", "겸", "온", "주", "민", "경", "담", "랑", "후", "결", "찬", "겸", "운", "재", ]; const WESTERN_FIRST_F = ["Aria", "Mira", "Selene", "Clara", "Iris", "Luna", "Vera", "Elena", "Rowan", "Nadia"]; const WESTERN_FIRST_M = ["Theo", "Darian", "Leon", "Rowan", "Gareth", "Milo", "Cedric", "Lucan", "Evan", "Noel"]; const DIASPORA_FIRST_F = ["Amara", "Zuri", "Nia", "Imani", "Asha", "Maya", "Talia", "Sade", "Kiana", "Leona"]; const DIASPORA_FIRST_M = ["Malik", "Kofi", "Dante", "Amari", "Jalen", "Sekou", "Idris", "Kellan", "Tariq", "Omar"]; const SOUTHERN_FIRST_F = ["Anika", "Priya", "Mira", "Leela", "Sahana", "Kavya", "Riya", "Tara", "Nila", "Diya"]; const SOUTHERN_FIRST_M = ["Arun", "Dev", "Rohan", "Kiran", "Naveen", "Vikram", "Ishan", "Ravi", "Tej", "Ari"]; const ELVEN_FIRST = ["Aelrin", "Seriel", "Liora", "Vaelis", "Elyndra", "Nimue", "Thalen", "Arieth", "Sylra", "Cael"]; const DWARVEN_FIRST = ["Bora", "Dagna", "Miri", "Hilda", "Kara", "Rurik", "Bram", "Torin", "Vera", "Nori"]; const FANTASY_FIRST = ["Rinova", "Kaelra", "Ysol", "Mordai", "Eona", "Veyra", "Lumik", "Ashen", "Soryn", "Velka"]; const FAMILY_NAMES = ["Vale", "Stone", "Moon", "Ash", "River", "Storm", "Hart", "Crown", "Reed", "Black", "Dawn", "Forge"]; const CALLSIGNS = ["첫별", "은침", "비문", "유성", "흑단", "청운", "적송", "백금", "잔월", "녹영"]; const state = { round: 0, player: null, enemy: null, autoTimer: null, locked: false, }; const artState = { available: new Set(), manifestLoaded: false, completedImageCount: 0, expectedImageCount: CHARACTER_COUNT * DAMAGE_ART_STAGES, completedCharacterCount: 0, }; const els = { playerCard: document.querySelector("#playerCard"), enemyCard: document.querySelector("#enemyCard"), playerStatus: document.querySelector("#playerStatus"), enemyStatus: document.querySelector("#enemyStatus"), drawButton: document.querySelector("#drawButton"), attackButton: document.querySelector("#attackButton"), autoButton: document.querySelector("#autoButton"), roundNumber: document.querySelector("#roundNumber"), matchupPanel: document.querySelector("#matchupPanel"), battleLog: document.querySelector("#battleLog"), rosterGrid: document.querySelector("#rosterGrid"), characterCount: document.querySelector("#characterCount"), assetProgress: document.querySelector("#assetProgress"), emptyTemplate: document.querySelector("#emptyCardTemplate"), }; const CHARACTERS = buildCharacterPool(CHARACTER_COUNT); loadAvailableCardArt(); function mulberry32(seed) { return function next() { let value = (seed += 0x6d2b79f5); value = Math.imul(value ^ (value >>> 15), value | 1); value ^= value + Math.imul(value ^ (value >>> 7), value | 61); return ((value ^ (value >>> 14)) >>> 0) / 4294967296; }; } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function randomIntRng(rng, min, max) { return Math.floor(rng() * (max - min + 1)) + min; } function pick(list) { return list[randomInt(0, list.length - 1)]; } function pickRng(list, rng) { return list[Math.floor(rng() * list.length)]; } function pickByIndex(list, index, salt = 0) { return list[Math.abs(index * 37 + salt * 17) % list.length]; } function weightedPick(list, rng) { const total = list.reduce((sum, item) => sum + item.weight, 0); let roll = rng() * total; for (const item of list) { roll -= item.weight; if (roll <= 0) return item; } return list[0]; } function buildCharacterPool(count) { const usedNames = new Set(); const characters = []; for (let index = 0; index < count; index += 1) { const rng = mulberry32(0xc0ffee + index * 7919); const gender = "여성"; const race = weightedPick(RACE_PROFILES, rng); const body = pickRng(BODY_TYPES, rng); const legacyWeapon = pickRng(WEAPONS, rng); const worldProfile = index < 3 ? null : WORLD_ARCHETYPES[(index - 3) % WORLD_ARCHETYPES.length]; const weapon = worldProfile ? { name: worldProfile.weapon, role: worldProfile.role, bias: worldProfile.bias, } : legacyWeapon; const personality = pickRng(PERSONALITIES, rng); const pose = worldProfile ? worldProfile.pose : pickRng(POSES, rng); const origin = worldProfile ? worldProfile.origin : pickRng(ORIGINS, rng); const damageProfile = worldProfile ? worldProfile.damageProfile : pickRng(DAMAGE_PROFILES, rng); const affinity = worldProfile?.affinity ?? ELEMENT_KEYS[(index + Math.floor(rng() * ELEMENT_KEYS.length)) % ELEMENT_KEYS.length]; const tier = weightedPick(STAR_TIERS, rng); const name = uniqueName(createName(index, gender, race, rng), usedNames, index); const title = `${pickByIndex(TITLE_PREFIXES, index, tier.stars)} ${ELEMENTS[affinity].name}${pickByIndex(TITLE_NOUNS, index, weapon.name.length)}`; const visual = makeVisualVariant(index, affinity, rng); const stats = makeStats(tier, race.bias, body.bias, weapon.bias, rng); const appearance = makeAppearance(index, rng); const appealFocus = pickByIndex( FEMALE_APPEAL_FOCUS, index, body.name.length + weapon.name.length + appearance.faceShape.length, ); characters.push({ id: `C${String(index + 1).padStart(4, "0")}`, index, name, gender, race: race.name, body: body.name, affinity, stars: tier.stars, title, role: weapon.role, weapon: weapon.name, personality, pose, origin, world: worldProfile?.world ?? "고전 판타지 전장", era: worldProfile?.era ?? "중세풍 판타지", scene: worldProfile?.scene ?? `fantasy duel arena with ${ELEMENTS[affinity].name} elemental atmosphere, no other characters`, costume: worldProfile?.costume ?? "ornate fantasy armor, cloak, weapon accessories, and battle-ready protective gear", style: worldProfile?.style ?? "polished semi-real painterly TCG key art, high-detail Korean/Asian fantasy mixed with global high-fantasy design", damageProfile, appealFocus, appearance, sprite: index % (SPRITE_COLUMNS * SPRITE_ROWS), visual, stats, }); } return characters; } function createName(index, gender, race, rng) { if (race.nameMode === "korean") { const surname = pickByIndex(KOREAN_SURNAMES, index, Math.floor(rng() * 19)); const first = pickByIndex(GIVEN_A, index, gender === "남성" ? 5 : 2); const second = pickByIndex(GIVEN_B, index, Math.floor(rng() * 23)); return `${surname}${first}${second}`; } if (race.nameMode === "western") { const first = pickByIndex(gender === "남성" ? WESTERN_FIRST_M : WESTERN_FIRST_F, index, 4); const family = pickByIndex(FAMILY_NAMES, index, 2); return `${first} ${family}`; } if (race.nameMode === "diaspora") { const first = pickByIndex(gender === "남성" ? DIASPORA_FIRST_M : DIASPORA_FIRST_F, index, 7); const family = pickByIndex(FAMILY_NAMES, index, 9); return `${first} ${family}`; } if (race.nameMode === "southern") { const first = pickByIndex(gender === "남성" ? SOUTHERN_FIRST_M : SOUTHERN_FIRST_F, index, 1); const family = pickByIndex(FAMILY_NAMES, index, 6); return `${first} ${family}`; } if (race.nameMode === "elven") { const first = pickByIndex(ELVEN_FIRST, index, gender === "남성" ? 3 : 8); const family = pickByIndex(["Lunaris", "Aster", "Willow", "Eversong", "Silva", "Vael"], index, 5); return `${first} ${family}`; } if (race.nameMode === "dwarven") { const first = pickByIndex(DWARVEN_FIRST, index, gender === "남성" ? 6 : 2); const family = pickByIndex(["Ironvein", "Copperhand", "Deepforge", "Stonehall", "Bronzebrow", "Ashanvil"], index, 3); return `${first} ${family}`; } const first = pickByIndex(FANTASY_FIRST, index, 11); const family = pickByIndex(["Riftborn", "Emberline", "Mistward", "Duskmark", "Starvein", "Cloudscar"], index, 4); return `${first} ${family}`; } function uniqueName(baseName, usedNames, index) { let candidate = baseName; let cursor = 0; while (usedNames.has(candidate)) { candidate = `${baseName} ${pickByIndex(CALLSIGNS, index, cursor)}`; cursor += 1; } usedNames.add(candidate); return candidate; } function makeVisualVariant(index, affinity, rng) { const elementOffset = { water: -16, metal: 4, earth: 18, fire: 32, wood: -36 }[affinity]; return { hue: Math.round(elementOffset + rng() * 54 - 27), sat: (0.88 + rng() * 0.36).toFixed(2), bright: (0.9 + rng() * 0.18).toFixed(2), accent: pickByIndex(["#59a8d8", "#c2c8cb", "#b8874d", "#e15f3d", "#68aa72", "#d6a849"], index, 3), }; } function makeStats(tier, raceBias, bodyBias, weaponBias, rng) { const hp = randomIntRng(rng, tier.hp[0], tier.hp[1]) * raceBias.hp * bodyBias.hp * weaponBias.hp; const atk = randomIntRng(rng, tier.atk[0], tier.atk[1]) * raceBias.atk * bodyBias.atk * weaponBias.atk; const def = randomIntRng(rng, tier.def[0], tier.def[1]) * raceBias.def * bodyBias.def * weaponBias.def; return { hp: Math.max(45, Math.round(hp)), atk: Math.max(5, Math.round(atk)), def: Math.max(2, Math.round(def)), }; } function makeAppearance(index, rng) { return { faceShape: pickByIndex(FACE_SHAPES, index, Math.floor(rng() * 11)), eyeStyle: pickByIndex(EYE_STYLES, index, Math.floor(rng() * 13)), noseStyle: pickByIndex(NOSE_STYLES, index, Math.floor(rng() * 17)), lipStyle: pickByIndex(LIP_STYLES, index, Math.floor(rng() * 19)), skinTone: pickByIndex(SKIN_TONES, index, Math.floor(rng() * 23)), hairStyle: pickByIndex(HAIR_STYLES, index, Math.floor(rng() * 29)), bodyDetail: pickByIndex(BODY_DETAILS, index, Math.floor(rng() * 31)), bustShape: pickByIndex(BUST_SHAPES, index, Math.floor(rng() * 37)), presence: pickByIndex(PRESENCE_STYLES, index, Math.floor(rng() * 41)), }; } function appearanceSummary(appearance) { return `${appearance.faceShape}, ${appearance.eyeStyle}, ${appearance.noseStyle}, ${appearance.lipStyle}, ${appearance.skinTone}, ${appearance.hairStyle}, ${appearance.bodyDetail}, ${appearance.bustShape}, ${appearance.presence}`; } function spritePosition(index) { const col = index % SPRITE_COLUMNS; const row = Math.floor(index / SPRITE_COLUMNS); return { x: SPRITE_COLUMNS === 1 ? "0%" : `${(col / (SPRITE_COLUMNS - 1)) * 100}%`, y: SPRITE_ROWS === 1 ? "0%" : `${(row / (SPRITE_ROWS - 1)) * 100}%`, }; } function createCard(owner, excludedId = null) { let character = pick(CHARACTERS); let guard = 0; while (character.id === excludedId && guard < 12) { character = pick(CHARACTERS); guard += 1; } return { id: `${owner}-${Date.now()}-${Math.random().toString(16).slice(2)}`, owner, character, stars: character.stars, elementKey: character.affinity, hp: character.stats.hp, maxHp: character.stats.hp, atk: character.stats.atk, def: character.stats.def, }; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function hpPct(card) { return `${clamp((card.hp / card.maxHp) * 100, 0, 100)}%`; } function damageStage(card) { const ratio = card.hp / card.maxHp; if (card.hp <= 0) return 4; if (ratio <= 0.2) return 3; if (ratio <= 0.48) return 2; if (ratio <= 0.76) return 1; return 0; } function damageText(stage) { return ["장비 정상", "의상 손상", "방어구 균열", "장비 파손", "전투 불능"][stage]; } function stageExpressionText(stage) { return [ "자신감 있는 준비 표정", "첫 타격에 짜증 섞인 찡그림", "복부 타격에 숨이 막히는 찡그림", "입이 벌어진 고통, 눈물, 가쁜 숨", "복부 타격 후 침이 흐르는 패배 고통", ][stage]; } function stageDamageFocus(stage) { return [ "모든 갑주와 의상이 온전함", "장갑, 망토 끝, 어깨 끈 일부 손상", "손·팔·어깨·복부 외피가 일부 사라짐", "손·발·어깨·복부 갑주가 크게 파괴됨", "손·발·어깨·복부 보호구가 대부분 파손됨", ][stage]; } function stageCameraText(stage) { return [ "정면 또는 3/4 정면 기준 원화", "약한 측면이 섞인 3/4 정면", "복부 충격이 보이는 측면/측전면", "측면 또는 후면 3/4 시점", "측후면/후면 3/4 패배 구도", ][stage]; } function artKey(characterId, stage) { return `${characterId}:${stage}`; } function cardArtPath(character, stage) { return `${CARD_ART_ROOT}/${character.id}/damage-${stage}.png`; } function cardArtUrl(character, stage) { return `url('${cardArtPath(character, stage)}')`; } function hasCardArt(character, stage) { return artState.available.has(artKey(character.id, stage)); } function assetSlotText(character, stage) { if (hasCardArt(character, stage)) return `HD 원화 ${stage}`; return `원화 제작 대기 · ${character.id}/damage-${stage}.png`; } function starText(stars) { return "★".repeat(stars) + "☆".repeat(5 - stars); } function elementClass(key) { return key; } function makeCardNode(card) { const element = ELEMENTS[card.elementKey]; const pos = spritePosition(card.character.sprite); const stage = damageStage(card); const hasHdArt = hasCardArt(card.character, stage); const article = document.createElement("article"); article.className = `battle-card ${elementClass(card.elementKey)}`; article.dataset.damage = String(stage); article.dataset.artReady = hasHdArt ? "true" : "false"; article.style.setProperty("--card-art-url", cardArtUrl(card.character, stage)); article.style.setProperty("--sprite-art-url", SPRITE_ART_URL); article.style.setProperty("--sprite-x", pos.x); article.style.setProperty("--sprite-y", pos.y); article.style.setProperty("--art-hue", `${card.character.visual.hue}deg`); article.style.setProperty("--art-sat", card.character.visual.sat); article.style.setProperty("--art-bright", card.character.visual.bright); article.style.setProperty("--accent-color", card.character.visual.accent); article.innerHTML = `
${character.personality} · ${character.world}
${character.appearance.faceShape} · ${character.appearance.eyeStyle} · ${character.appearance.bodyDetail} ${character.appealFocus} ${character.pose} `; return item; }); els.rosterGrid.replaceChildren(...nodes); if (els.characterCount) els.characterCount.textContent = String(CHARACTERS.length); } els.drawButton.addEventListener("click", drawCards); els.attackButton.addEventListener("click", resolveExchange); els.autoButton.addEventListener("click", toggleAuto); renderRoster(); renderCards(); renderAssetProgress();