1441 lines
57 KiB
JavaScript
1441 lines
57 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 WORLD_ARCHETYPES = [
|
|
{
|
|
world: "현대 한국 캠퍼스 이능 결투",
|
|
era: "현시대 대학가",
|
|
affinity: "fire",
|
|
role: "화염술 대학생",
|
|
weapon: "불꽃을 쏘는 스마트폰 촉매와 강의노트",
|
|
origin: "서울 사립대 마법동아리 출신",
|
|
pose: "후드 집업을 휘날리며 손끝에서 불꽃을 쏘는 자세",
|
|
scene: "university campus plaza, lecture buildings, vending machines, bike racks, burning magic circles, fire elemental atmosphere",
|
|
lighting: "clear late-afternoon daylight with warm sun, visible blue sky, long shadows, and bright campus detail",
|
|
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 daylight 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",
|
|
lighting: "bright indoor concert lighting with white-blue spotlights and reflective stage water, not a dark night scene",
|
|
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, subtle chrome reflections, metal elemental atmosphere",
|
|
lighting: "rainy early-evening neon with readable face light and wet reflections",
|
|
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, large floating debris, gravity lensing, earth elemental atmosphere",
|
|
lighting: "brilliant orbital daylight through station glass, Earth-reflected blue-white light, crisp sci-fi visibility",
|
|
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",
|
|
lighting: "low sunset daylight with hard orange sun and dust glare",
|
|
costume: "racing leather, armored crop jacket, reinforced boots, torn scarf, heat-resistant plates",
|
|
style: "post-apocalyptic action card art, dust trails, 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",
|
|
lighting: "bright sterile hospital lighting with green bio-magic glow and clear clinical visibility",
|
|
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",
|
|
lighting: "sunny high-altitude afternoon with warm brass reflections and white steam",
|
|
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",
|
|
lighting: "overcast rainy daytime noir with pale street reflections and soft gray sky",
|
|
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, large floating stone plates, earth elemental atmosphere",
|
|
lighting: "bright indoor arena lighting like a televised daytime sports match",
|
|
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",
|
|
lighting: "clear underwater daylight beams from the surface mixed with coral glow",
|
|
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, large frozen glass panels, metal elemental atmosphere",
|
|
lighting: "surreal golden noon frozen in time, bright highlights on gears and glass",
|
|
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: "open-air food market, colorful stalls, steam, flying spices, fire elemental atmosphere",
|
|
lighting: "busy lunchtime daylight with awnings, steam, and warm flame contrast",
|
|
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",
|
|
lighting: "cold polar morning with pale sun through snow and faint aurora color",
|
|
costume: "thermal research suit, fur-trim hood, harness, insulated boots, cracked goggles",
|
|
style: "arctic science fantasy card art, aurora lighting, soft snow haze, 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",
|
|
lighting: "bright Mars daytime with red dust haze and industrial work lights",
|
|
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",
|
|
lighting: "golden daytime temple courtyard with fashion runway spotlights",
|
|
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",
|
|
lighting: "harsh noon sun in a dusty western street, bright and dry",
|
|
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, clean data panels, giant screens, data shields, metal elemental atmosphere",
|
|
lighting: "bright virtual arena lighting with white LED panels and colorful UI glow",
|
|
costume: "streamer performance suit, LED jacket, headset, fingerless gloves, hologram belt",
|
|
style: "neon esports fantasy card art, clean digital effects, energetic streaming-stage composition",
|
|
damageProfile: "LED 재킷, 헤드셋, 장갑, 홀로그램 벨트와 데이터 방패가 깨진다",
|
|
bias: { hp: 0.94, atk: 1.1, def: 1 },
|
|
},
|
|
{
|
|
world: "전통 시장 도깨비 야간전",
|
|
era: "현대 민속 판타지",
|
|
affinity: "wood",
|
|
role: "부적 상인",
|
|
weapon: "나무 부적 타래와 접이식 장대",
|
|
origin: "심야 전통시장 노점 출신",
|
|
pose: "부적 타래를 펼치며 좁은 골목을 막아서는 자세",
|
|
scene: "traditional market alley, paper lanterns, wooden talismans, alley mist, wood elemental atmosphere",
|
|
lighting: "late-afternoon market light with lanterns beginning to glow, not fully night",
|
|
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",
|
|
lighting: "bright luxury interior lighting with gold reflections and clear face visibility",
|
|
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",
|
|
lighting: "humid midday jungle sunlight with strong beams through leaves",
|
|
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 LIGHTING_VARIANTS = [
|
|
"clear midday daylight with crisp shadows and bright environmental color",
|
|
"late-afternoon sunlight with warm rim light and readable faces",
|
|
"overcast daytime with soft gray sky and high detail visibility",
|
|
"early morning light with cool blue shadows and gentle sun",
|
|
"stormy daytime with dramatic clouds, rain, and bright lightning accents",
|
|
"golden sunset with strong side light and dust or mist glow",
|
|
"bright indoor arena or stage lighting with clean highlights",
|
|
"night battle lighting with moon, neon, or magical glow used only as one variant among many",
|
|
];
|
|
|
|
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`,
|
|
lighting: worldProfile?.lighting ?? pickByIndex(LIGHTING_VARIANTS, index, weapon.name.length),
|
|
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 = `
|
|
<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>
|
|
<span class="tag">${card.character.era}</span>
|
|
</div>
|
|
|
|
<div class="character-notes">
|
|
<div><strong>성향</strong><span>${card.character.personality}</span></div>
|
|
<div><strong>세계관</strong><span>${card.character.world}</span></div>
|
|
<div><strong>시간/조명</strong><span>${card.character.lighting}</span></div>
|
|
<div><strong>복장/장비</strong><span>${card.character.costume}</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.world}</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();
|