1109 lines
37 KiB
JavaScript
1109 lines
37 KiB
JavaScript
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 = `
|
|
<div class="card-title-row">
|
|
<div class="card-name">
|
|
<strong>${card.character.name}</strong>
|
|
<span>${card.character.id} · ${card.character.title} · ${card.character.role}</span>
|
|
</div>
|
|
<div class="stars" aria-label="${card.stars}성">${starText(card.stars)}</div>
|
|
</div>
|
|
|
|
<div class="card-art">
|
|
<span class="element-badge">${element.hanja}</span>
|
|
<span class="damage-tag">${damageText(stage)}</span>
|
|
<span class="asset-tag">${assetSlotText(card.character, stage)}</span>
|
|
</div>
|
|
|
|
<div class="card-tags">
|
|
<span class="element-chip">${element.name} 속성</span>
|
|
<span class="tag">${card.character.gender}</span>
|
|
<span class="tag">${card.character.race}</span>
|
|
<span class="tag">${card.character.body}</span>
|
|
<span class="tag">${card.character.weapon}</span>
|
|
</div>
|
|
|
|
<div class="character-notes">
|
|
<div><strong>성향</strong><span>${card.character.personality}</span></div>
|
|
<div><strong>성인 연출</strong><span>${card.character.appealFocus}</span></div>
|
|
<div><strong>외형 DNA</strong><span>${appearanceSummary(card.character.appearance)}</span></div>
|
|
<div><strong>포즈</strong><span>${card.character.pose}</span></div>
|
|
<div><strong>출신</strong><span>${card.character.origin}</span></div>
|
|
<div><strong>패배 표정</strong><span>${stageExpressionText(stage)}</span></div>
|
|
<div><strong>시점</strong><span>${stageCameraText(stage)}</span></div>
|
|
<div><strong>파손 부위</strong><span>${stageDamageFocus(stage)}</span></div>
|
|
<div><strong>손상 연출</strong><span>${card.character.damageProfile}</span></div>
|
|
</div>
|
|
|
|
<div class="hp-block">
|
|
<div class="hp-line">
|
|
<span>체력</span>
|
|
<strong>${card.hp} / ${card.maxHp}</strong>
|
|
</div>
|
|
<div class="hp-bar" aria-label="체력 ${card.hp} / ${card.maxHp}">
|
|
<div class="hp-fill" style="--hp-pct: ${hpPct(card)}"></div>
|
|
</div>
|
|
<div class="stats-row">
|
|
<div class="stat"><span>공격</span><strong>${card.atk}</strong></div>
|
|
<div class="stat"><span>방어</span><strong>${card.def}</strong></div>
|
|
<div class="stat"><span>손상</span><strong>${stage}</strong></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `<span class="muted">카드를 뽑으면 상성이 표시됩니다.</span>`;
|
|
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 = `
|
|
<div class="matchup-title">
|
|
<span class="${playerClass}">${playerElement.name}</span>
|
|
<span class="muted"> vs </span>
|
|
<span class="${enemyClass}">${enemyElement.name}</span>
|
|
</div>
|
|
<div class="matchup-detail">
|
|
플레이어 피해 배율 <strong class="${playerClass}">x${playerMod.toFixed(2)}</strong><br />
|
|
상대 피해 배율 <strong class="${enemyClass}">x${enemyMod.toFixed(2)}</strong>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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(`<strong>${state.round}라운드.</strong> ${state.player.character.name}와 ${state.enemy.character.name}가 소환되었습니다.`);
|
|
addLog(`전체 캐릭터 풀 <strong>${CHARACTERS.length}</strong>명 중 무작위 결투입니다.`);
|
|
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(
|
|
`<strong>${label}</strong> ${attacker.character.name}의 공격. ${defender.character.name}에게 ` +
|
|
`<strong>${result.damage}</strong> 피해 (${boostText}).`,
|
|
);
|
|
if (result.brokeGear) {
|
|
addLog(`${defender.character.name}: <strong>${damageText(result.stage)}</strong>. ${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(`<strong>${result}.</strong> 새 카드를 뽑아 다음 결투를 시작할 수 있습니다.`);
|
|
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 = `
|
|
<div class="mini-art" aria-hidden="true"></div>
|
|
<strong>${character.name}</strong>
|
|
<div class="mini-meta">
|
|
<span>${character.id} · ${character.gender} · ${character.race}</span>
|
|
<span class="element-chip">${element.name}</span>
|
|
</div>
|
|
<p>${character.personality} · ${character.origin}</p>
|
|
<small>${character.appearance.faceShape} · ${character.appearance.eyeStyle} · ${character.appearance.bodyDetail}</small>
|
|
<small>${character.appealFocus}</small>
|
|
<small>${character.pose}</small>
|
|
`;
|
|
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();
|