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 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 weapon = pickRng(WEAPONS, rng); const personality = pickRng(PERSONALITIES, rng); const pose = pickRng(POSES, rng); const origin = pickRng(ORIGINS, rng); const damageProfile = pickRng(DAMAGE_PROFILES, rng); const 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, 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 = `
${card.character.name} ${card.character.id} · ${card.character.title} · ${card.character.role}
${starText(card.stars)}
${element.hanja} ${damageText(stage)} ${assetSlotText(card.character, stage)}
${element.name} 속성 ${card.character.gender} ${card.character.race} ${card.character.body} ${card.character.weapon}
성향${card.character.personality}
성인 연출${card.character.appealFocus}
외형 DNA${appearanceSummary(card.character.appearance)}
포즈${card.character.pose}
출신${card.character.origin}
패배 표정${stageExpressionText(stage)}
시점${stageCameraText(stage)}
파손 부위${stageDamageFocus(stage)}
손상 연출${card.character.damageProfile}
체력 ${card.hp} / ${card.maxHp}
공격${card.atk}
방어${card.def}
손상${stage}
`; return article; } function renderEmpty(slot) { slot.replaceChildren(els.emptyTemplate.content.cloneNode(true)); } function renderCards() { if (state.player) { els.playerCard.replaceChildren(makeCardNode(state.player)); } else { renderEmpty(els.playerCard); } if (state.enemy) { els.enemyCard.replaceChildren(makeCardNode(state.enemy)); } else { renderEmpty(els.enemyCard); } els.roundNumber.textContent = String(state.round); els.playerStatus.textContent = state.player ? statusText(state.player) : "대기"; els.enemyStatus.textContent = state.enemy ? statusText(state.enemy) : "대기"; renderMatchup(); updateButtons(); } function statusText(card) { if (card.hp <= 0) return "패배"; return damageText(damageStage(card)); } function renderMatchup() { if (!state.player || !state.enemy) { els.matchupPanel.innerHTML = `카드를 뽑으면 상성이 표시됩니다.`; return; } const playerElement = ELEMENTS[state.player.elementKey]; const enemyElement = ELEMENTS[state.enemy.elementKey]; const playerMod = elementModifier(state.player.elementKey, state.enemy.elementKey); const enemyMod = elementModifier(state.enemy.elementKey, state.player.elementKey); const playerClass = playerMod > 1 ? "advantage" : playerMod < 1 ? "disadvantage" : ""; const enemyClass = enemyMod > 1 ? "advantage" : enemyMod < 1 ? "disadvantage" : ""; els.matchupPanel.innerHTML = `
${playerElement.name} vs ${enemyElement.name}
플레이어 피해 배율 x${playerMod.toFixed(2)}
상대 피해 배율 x${enemyMod.toFixed(2)}
`; } function updateButtons() { const ready = Boolean(state.player && state.enemy); const finished = ready && (state.player.hp <= 0 || state.enemy.hp <= 0); els.attackButton.disabled = !ready || finished || state.locked; els.autoButton.disabled = !ready || finished || state.locked; els.autoButton.textContent = state.autoTimer ? "자동 중지" : "자동 전투"; } function elementModifier(attackerKey, defenderKey) { if (ELEMENTS[attackerKey].beats === defenderKey) return 1.35; if (ELEMENTS[defenderKey].beats === attackerKey) return 0.72; return 1; } function attack(attacker, defender) { const beforeStage = damageStage(defender); const elementBoost = elementModifier(attacker.elementKey, defender.elementKey); const variance = 0.9 + Math.random() * 0.22; const starPressure = 1 + (attacker.stars - defender.stars) * 0.045; const guarded = defender.def * 0.58; const raw = attacker.atk * elementBoost * starPressure * variance - guarded; const damage = Math.max(3, Math.round(raw)); defender.hp = clamp(defender.hp - damage, 0, defender.maxHp); const afterStage = damageStage(defender); return { damage, elementBoost, brokeGear: afterStage > beforeStage, stage: afterStage, defeated: defender.hp <= 0, }; } function addLog(message) { const item = document.createElement("li"); item.innerHTML = message; els.battleLog.prepend(item); while (els.battleLog.children.length > 18) { els.battleLog.lastElementChild.remove(); } } function drawCards() { stopAuto(); state.round += 1; state.player = createCard("player"); state.enemy = createCard("enemy", state.player.character.id); els.battleLog.replaceChildren(); addLog(`${state.round}라운드. ${state.player.character.name}와 ${state.enemy.character.name}가 소환되었습니다.`); addLog(`전체 캐릭터 풀 ${CHARACTERS.length}명 중 무작위 결투입니다.`); const mod = elementModifier(state.player.elementKey, state.enemy.elementKey); if (mod > 1) addLog("플레이어가 상성 우위를 잡았습니다."); if (mod < 1) addLog("상대가 상성 우위를 잡았습니다."); renderCards(); } function resolveExchange() { if (!state.player || !state.enemy || state.player.hp <= 0 || state.enemy.hp <= 0) return; state.locked = true; updateButtons(); const playerResult = attack(state.player, state.enemy); reportAttack(state.player, state.enemy, playerResult, "플레이어"); if (playerResult.defeated) { finishBattle("플레이어 승리"); return; } const enemyResult = attack(state.enemy, state.player); reportAttack(state.enemy, state.player, enemyResult, "상대"); if (enemyResult.defeated) { finishBattle("상대 승리"); return; } state.locked = false; renderCards(); } function reportAttack(attacker, defender, result, label) { const boostText = result.elementBoost > 1 ? "상성 우위" : result.elementBoost < 1 ? "상성 열세" : "상성 보통"; addLog( `${label} ${attacker.character.name}의 공격. ${defender.character.name}에게 ` + `${result.damage} 피해 (${boostText}).`, ); if (result.brokeGear) { addLog(`${defender.character.name}: ${damageText(result.stage)}. ${defender.character.damageProfile}`); } } async function loadAvailableCardArt() { try { const response = await fetch(`${CARD_ART_ROOT}/available-assets.json`, { cache: "no-store" }); if (!response.ok) return; const payload = await response.json(); const assets = Array.isArray(payload.assets) ? payload.assets : []; artState.available = new Set(assets.map((asset) => artKey(asset.characterId, asset.damageStage))); artState.completedImageCount = Number(payload.completedImageCount) || assets.length; artState.expectedImageCount = Number(payload.expectedImageCount) || CHARACTER_COUNT * DAMAGE_ART_STAGES; artState.completedCharacterCount = Number(payload.completedCharacterCount) || new Set(assets.map((asset) => asset.characterId)).size; artState.manifestLoaded = true; renderCards(); renderRoster(); renderAssetProgress(); } catch { artState.manifestLoaded = false; } } function renderAssetProgress() { if (!els.assetProgress) return; els.assetProgress.textContent = `${artState.completedImageCount} / ${artState.expectedImageCount}`; } function finishBattle(result) { stopAuto(); addLog(`${result}. 새 카드를 뽑아 다음 결투를 시작할 수 있습니다.`); state.locked = false; renderCards(); } function toggleAuto() { if (state.autoTimer) { stopAuto(); updateButtons(); return; } if (!state.player || !state.enemy) return; state.autoTimer = window.setInterval(() => { if (!state.player || !state.enemy || state.player.hp <= 0 || state.enemy.hp <= 0) { stopAuto(); renderCards(); return; } resolveExchange(); }, 680); updateButtons(); } function stopAuto() { if (!state.autoTimer) return; window.clearInterval(state.autoTimer); state.autoTimer = null; } function renderRoster() { const nodes = CHARACTERS.map((character) => { const element = ELEMENTS[character.affinity]; const pos = spritePosition(character.sprite); const hasHdArt = hasCardArt(character, 0); const item = document.createElement("article"); item.className = `mini-card ${character.affinity}`; item.dataset.artReady = hasHdArt ? "true" : "false"; item.style.setProperty("--card-art-url", cardArtUrl(character, 0)); item.style.setProperty("--sprite-art-url", SPRITE_ART_URL); item.style.setProperty("--sprite-x", pos.x); item.style.setProperty("--sprite-y", pos.y); item.style.setProperty("--art-hue", `${character.visual.hue}deg`); item.style.setProperty("--art-sat", character.visual.sat); item.style.setProperty("--art-bright", character.visual.bright); item.innerHTML = ` ${character.name}
${character.id} · ${character.gender} · ${character.race} ${element.name}

${character.personality} · ${character.origin}

${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();