Initial card game asset pipeline
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Local environment and secrets
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Dependency folders
|
||||
node_modules/
|
||||
|
||||
# OS/editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Runtime logs and temporary files
|
||||
*.log
|
||||
tmp/
|
||||
temp/
|
||||
BIN
assets/cards/C0001/contact-sheet.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/cards/C0001/damage-0.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
assets/cards/C0001/damage-1.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
assets/cards/C0001/damage-2.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0001/damage-3.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
assets/cards/C0001/damage-4.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0002/contact-sheet.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/cards/C0002/damage-0.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0002/damage-1.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0002/damage-2.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0002/damage-3.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0002/damage-4.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0003/damage-0.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
assets/cards/C0003/damage-1.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/cards/C0003/damage-2.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
39
assets/cards/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Card Illustration Assets
|
||||
|
||||
The game expects one independent high-resolution illustration per character and damage stage:
|
||||
|
||||
```text
|
||||
assets/cards/C0001/damage-0.png
|
||||
assets/cards/C0001/damage-1.png
|
||||
assets/cards/C0001/damage-2.png
|
||||
assets/cards/C0001/damage-3.png
|
||||
assets/cards/C0001/damage-4.png
|
||||
```
|
||||
|
||||
With 1000 all-female characters and 5 damage stages, final production requires 5000 image files.
|
||||
|
||||
- `damage-0.png`: intact armor/clothing
|
||||
- `damage-1.png`: light outerwear or armor damage
|
||||
- `damage-2.png`: moderate armor cracks and torn outer garments
|
||||
- `damage-3.png`: heavy armor destruction and shredded outer layers
|
||||
- `damage-4.png`: defeated state with broken equipment
|
||||
|
||||
The game art direction is adult-oriented fantasy pinup:
|
||||
|
||||
- Characters should emphasize mature curvy female silhouettes and large-breast armor/clothing design.
|
||||
- Every character should have a distinct appearance DNA: face shape, eye shape, nose, lips, skin tone, hair, body proportions, bust shape, and overall presence. Avoid generating the same default pretty face and same body repeatedly.
|
||||
- Damage-state art should show armor destruction, torn outer clothing, broken belts, damaged bracers, dust, scorch marks, and defeated poses.
|
||||
- Defeated and high-damage stages should show emotional defeat: frustrated tears, resentful grimaces, clenched teeth, wet eyes, and expressions that feel unfair or humiliating rather than calm dignity.
|
||||
- Medium through defeated stages may show clear combat-pain reactions from abdominal hits: gasping open mouth, hunched posture, labored breathing, tears, and small saliva/drool details. These must read as combat injury reactions, not sexual acts or fetish framing.
|
||||
- High-damage stages should make the combat feel intense through missing or destroyed hand, foot, shoulder, and abdomen armor/clothing. Keep intact under-armor or fitted coverage over intimate areas.
|
||||
- Do not keep every card as a straight front shot. Use camera variety across stages: front/three-quarter for baseline, side or twisted side for abdomen impacts, and rear three-quarter or side-back views for heavy damage and defeated art when it helps show shoulder, back, cloak, and armor destruction.
|
||||
|
||||
All damage-state art should remain non-explicit: adult fantasy battle damage, no nudity, no visible nipples or genitals, no sexual acts, no childlike characters, and no gore.
|
||||
|
||||
Run this from the project root to regenerate the production manifest and prompts:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\MD\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' .\tools\export-card-assets.mjs
|
||||
```
|
||||
|
||||
Only completed image files should be listed in `available-assets.json`; the web app uses that file to decide when to show high-resolution card art instead of the temporary preview sheet.
|
||||
100
assets/cards/available-assets.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-06-09T17:05:24.000Z",
|
||||
"expectedImageCount": 5000,
|
||||
"completedImageCount": 13,
|
||||
"completedCharacterCount": 3,
|
||||
"assets": [
|
||||
{
|
||||
"characterId": "C0001",
|
||||
"damageStage": 0,
|
||||
"path": "assets/cards/C0001/damage-0.png",
|
||||
"bytes": 2839946,
|
||||
"updatedAt": "2026-06-09T15:45:42.250Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0001",
|
||||
"damageStage": 1,
|
||||
"path": "assets/cards/C0001/damage-1.png",
|
||||
"bytes": 3220323,
|
||||
"updatedAt": "2026-06-09T15:49:10.337Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0001",
|
||||
"damageStage": 2,
|
||||
"path": "assets/cards/C0001/damage-2.png",
|
||||
"bytes": 3153618,
|
||||
"updatedAt": "2026-06-09T15:52:02.562Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0001",
|
||||
"damageStage": 3,
|
||||
"path": "assets/cards/C0001/damage-3.png",
|
||||
"bytes": 3054567,
|
||||
"updatedAt": "2026-06-09T16:03:56.650Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0001",
|
||||
"damageStage": 4,
|
||||
"path": "assets/cards/C0001/damage-4.png",
|
||||
"bytes": 3194383,
|
||||
"updatedAt": "2026-06-09T16:06:43.042Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0002",
|
||||
"damageStage": 0,
|
||||
"path": "assets/cards/C0002/damage-0.png",
|
||||
"bytes": 3181889,
|
||||
"updatedAt": "2026-06-09T16:17:14.359Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0002",
|
||||
"damageStage": 1,
|
||||
"path": "assets/cards/C0002/damage-1.png",
|
||||
"bytes": 3191038,
|
||||
"updatedAt": "2026-06-09T16:20:39.899Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0002",
|
||||
"damageStage": 2,
|
||||
"path": "assets/cards/C0002/damage-2.png",
|
||||
"bytes": 3118934,
|
||||
"updatedAt": "2026-06-09T16:23:08.277Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0002",
|
||||
"damageStage": 3,
|
||||
"path": "assets/cards/C0002/damage-3.png",
|
||||
"bytes": 3163483,
|
||||
"updatedAt": "2026-06-09T16:26:29.618Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0002",
|
||||
"damageStage": 4,
|
||||
"path": "assets/cards/C0002/damage-4.png",
|
||||
"bytes": 3128572,
|
||||
"updatedAt": "2026-06-09T16:29:11.789Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0003",
|
||||
"damageStage": 0,
|
||||
"path": "assets/cards/C0003/damage-0.png",
|
||||
"bytes": 3224482,
|
||||
"updatedAt": "2026-06-09T16:40:24.149Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0003",
|
||||
"damageStage": 1,
|
||||
"path": "assets/cards/C0003/damage-1.png",
|
||||
"bytes": 3113006,
|
||||
"updatedAt": "2026-06-09T16:44:29.165Z"
|
||||
},
|
||||
{
|
||||
"characterId": "C0003",
|
||||
"damageStage": 2,
|
||||
"path": "assets/cards/C0003/damage-2.png",
|
||||
"bytes": 3143777,
|
||||
"updatedAt": "2026-06-09T16:50:00.690Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
66056
assets/cards/card-manifest.json
Normal file
5000
assets/cards/prompts/card-image-prompts.jsonl
Normal file
BIN
assets/roster-poses.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/roster.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
102
index.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>오행 카드 결투</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">五</span>
|
||||
<div>
|
||||
<h1>오행 카드 결투</h1>
|
||||
<p>수 · 금 · 지 · 화 · 목 상성으로 겨루는 판타지 카드 배틀</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-strip" aria-label="덱 구성">
|
||||
<span>1-5성</span>
|
||||
<span>1000명 캐릭터</span>
|
||||
<span>여성 캐릭터 전용</span>
|
||||
<span>원화 <b id="assetProgress">0 / 5000</b></span>
|
||||
<span>성인 캐릭터</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="battlefield" aria-label="전장">
|
||||
<article class="fighter-pane">
|
||||
<div class="pane-head">
|
||||
<span class="side-label">플레이어</span>
|
||||
<strong id="playerStatus">대기</strong>
|
||||
</div>
|
||||
<div id="playerCard" class="card-slot"></div>
|
||||
</article>
|
||||
|
||||
<section class="command-panel" aria-label="전투 조작">
|
||||
<div class="round-display">
|
||||
<span class="round-kicker">ROUND</span>
|
||||
<strong id="roundNumber">0</strong>
|
||||
</div>
|
||||
|
||||
<div id="matchupPanel" class="matchup-panel">
|
||||
<span class="muted">카드를 뽑으면 상성이 표시됩니다.</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="drawButton" type="button">카드 뽑기</button>
|
||||
<button id="attackButton" type="button" disabled>공격</button>
|
||||
<button id="autoButton" type="button" disabled>자동 전투</button>
|
||||
</div>
|
||||
|
||||
<div class="cycle-board" aria-label="오행 상성">
|
||||
<span class="element-token water">수</span>
|
||||
<span class="beats">›</span>
|
||||
<span class="element-token fire">화</span>
|
||||
<span class="beats">›</span>
|
||||
<span class="element-token metal">금</span>
|
||||
<span class="beats">›</span>
|
||||
<span class="element-token wood">목</span>
|
||||
<span class="beats">›</span>
|
||||
<span class="element-token earth">지</span>
|
||||
<span class="beats">›</span>
|
||||
<span class="element-token water">수</span>
|
||||
</div>
|
||||
|
||||
<div class="battle-log" aria-live="polite">
|
||||
<div class="log-title">전투 로그</div>
|
||||
<ol id="battleLog"></ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="fighter-pane">
|
||||
<div class="pane-head">
|
||||
<span class="side-label">상대</span>
|
||||
<strong id="enemyStatus">대기</strong>
|
||||
</div>
|
||||
<div id="enemyCard" class="card-slot"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="collection" aria-label="캐릭터 로스터">
|
||||
<div class="section-head">
|
||||
<h2>캐릭터 로스터</h2>
|
||||
<p><span id="characterCount">1000</span>명 모두 고유 이름과 서로 다른 성향 · 포즈 · 장비 · 출신 조합을 가집니다.</p>
|
||||
</div>
|
||||
<div id="rosterGrid" class="roster-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="emptyCardTemplate">
|
||||
<div class="empty-card">
|
||||
<span class="empty-mark">五</span>
|
||||
<strong>카드 없음</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
803
styles.css
Normal file
@@ -0,0 +1,803 @@
|
||||
:root {
|
||||
--bg: #141312;
|
||||
--panel: #221f1d;
|
||||
--panel-2: #2c2824;
|
||||
--ink: #f7f1e7;
|
||||
--muted: #b8aca0;
|
||||
--line: rgba(255, 244, 220, 0.16);
|
||||
--gold: #d6a849;
|
||||
--danger: #d9584a;
|
||||
--ok: #53b58d;
|
||||
--water: #59a8d8;
|
||||
--metal: #c2c8cb;
|
||||
--earth: #b8874d;
|
||||
--fire: #e15f3d;
|
||||
--wood: #68aa72;
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
"Segoe UI",
|
||||
"Noto Sans KR",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(130deg, rgba(27, 34, 31, 0.94), rgba(31, 24, 21, 0.98) 48%, rgba(22, 20, 19, 1)),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(255, 244, 220, 0.28);
|
||||
border-radius: 8px;
|
||||
background: #f1dfba;
|
||||
color: #191512;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
background: #fff0ca;
|
||||
border-color: rgba(255, 244, 220, 0.65);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 82px;
|
||||
padding: 14px 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.empty-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(214, 168, 73, 0.5);
|
||||
border-radius: 50%;
|
||||
background: #2a2319;
|
||||
color: var(--gold);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.55rem, 2.8vw, 2.45rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand p,
|
||||
.section-head p,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin-top: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.meta-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.meta-strip span,
|
||||
.tag,
|
||||
.element-chip {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 244, 220, 0.06);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-strip span {
|
||||
padding: 8px 11px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.battlefield {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(270px, 360px) minmax(260px, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fighter-pane,
|
||||
.command-panel,
|
||||
.collection {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(34, 31, 29, 0.84);
|
||||
box-shadow: 0 22px 80px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.fighter-pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pane-head,
|
||||
.section-head,
|
||||
.card-title-row,
|
||||
.stats-row,
|
||||
.card-tags,
|
||||
.mini-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.side-label,
|
||||
.round-kicker,
|
||||
.log-title {
|
||||
color: var(--gold);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pane-head strong {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-slot {
|
||||
display: grid;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
border: 1px dashed rgba(255, 244, 220, 0.22);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.battle-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 244, 220, 0.2);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #342e27, #171514);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.battle-card::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.card-art {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 244, 220, 0.18);
|
||||
border-radius: 6px;
|
||||
background-color: #171514;
|
||||
background-image: var(--card-art-url), var(--sprite-art-url);
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: cover, 500% 400%;
|
||||
background-position: center, var(--sprite-x) var(--sprite-y);
|
||||
filter: hue-rotate(var(--art-hue, 0deg)) saturate(var(--art-sat, 1)) brightness(var(--art-bright, 1));
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.battle-card[data-art-ready="false"] .card-art {
|
||||
background-image: var(--sprite-art-url);
|
||||
background-size: 500% 400%;
|
||||
background-position: var(--sprite-x) var(--sprite-y);
|
||||
}
|
||||
|
||||
.card-art::before,
|
||||
.card-art::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.card-art::before {
|
||||
background:
|
||||
linear-gradient(113deg, transparent 0 32%, rgba(245, 231, 198, 0.7) 33%, transparent 35% 100%),
|
||||
linear-gradient(71deg, transparent 0 55%, rgba(31, 22, 16, 0.72) 56%, transparent 59% 100%),
|
||||
linear-gradient(151deg, transparent 0 42%, rgba(245, 231, 198, 0.52) 43%, transparent 45% 100%);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.card-art::after {
|
||||
background:
|
||||
linear-gradient(118deg, transparent 0 52%, rgba(36, 21, 16, 0.8) 53%, transparent 55% 100%),
|
||||
linear-gradient(16deg, transparent 0 37%, rgba(214, 168, 73, 0.42) 38%, transparent 40% 100%),
|
||||
linear-gradient(170deg, transparent 0 64%, rgba(36, 21, 16, 0.72) 65%, transparent 67% 100%);
|
||||
}
|
||||
|
||||
.battle-card[data-damage="1"] .card-art::before {
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.battle-card[data-damage="2"] .card-art::before {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.battle-card[data-damage="2"] .card-art::after {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.battle-card[data-damage="3"] .card-art::before {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.battle-card[data-damage="3"] .card-art::after,
|
||||
.battle-card[data-damage="4"] .card-art::after {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.battle-card[data-damage="4"] .card-art {
|
||||
filter: hue-rotate(var(--art-hue, 0deg)) saturate(var(--art-sat, 1)) brightness(0.72) grayscale(0.35);
|
||||
}
|
||||
|
||||
.battle-card[data-damage="4"] .card-art::before {
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
.damage-tag {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 3;
|
||||
max-width: calc(100% - 20px);
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(255, 244, 220, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 17, 15, 0.75);
|
||||
color: #fff0ca;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.asset-tag {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 3;
|
||||
max-width: calc(100% - 20px);
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(255, 244, 220, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 17, 15, 0.75);
|
||||
color: #f4d99f;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.battle-card[data-art-ready="true"] .asset-tag {
|
||||
color: #9ff0d0;
|
||||
}
|
||||
|
||||
.element-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 14, 12, 0.76);
|
||||
color: var(--element-color);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name strong {
|
||||
overflow: hidden;
|
||||
font-size: 1.24rem;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-name span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: var(--gold);
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.character-notes {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(255, 244, 220, 0.12);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 244, 220, 0.045);
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.character-notes div {
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.character-notes strong {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.tag,
|
||||
.element-chip {
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.element-chip {
|
||||
color: var(--element-color);
|
||||
}
|
||||
|
||||
.hp-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.hp-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.hp-bar {
|
||||
height: 13px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 244, 220, 0.18);
|
||||
border-radius: 999px;
|
||||
background: #151312;
|
||||
}
|
||||
|
||||
.hp-fill {
|
||||
width: var(--hp-pct);
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--ok), #e0bd5a 62%, var(--danger));
|
||||
transition: width 240ms ease;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(255, 244, 220, 0.13);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: rgba(255, 244, 220, 0.045);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.command-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto minmax(220px, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.round-display {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(214, 168, 73, 0.32);
|
||||
border-radius: 8px;
|
||||
background: #281f16;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.round-display strong {
|
||||
font-size: 2.6rem;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.matchup-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 104px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 244, 220, 0.045);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.matchup-title {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.matchup-detail {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.advantage {
|
||||
color: #ffe19f;
|
||||
}
|
||||
|
||||
.disadvantage {
|
||||
color: #ff9a8f;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cycle-board {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.element-token {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 50%;
|
||||
color: #12100f;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.water {
|
||||
--element-color: var(--water);
|
||||
background-color: color-mix(in srgb, var(--water) 82%, #141312);
|
||||
}
|
||||
|
||||
.metal {
|
||||
--element-color: var(--metal);
|
||||
background-color: color-mix(in srgb, var(--metal) 82%, #141312);
|
||||
}
|
||||
|
||||
.earth {
|
||||
--element-color: var(--earth);
|
||||
background-color: color-mix(in srgb, var(--earth) 82%, #141312);
|
||||
}
|
||||
|
||||
.fire {
|
||||
--element-color: var(--fire);
|
||||
background-color: color-mix(in srgb, var(--fire) 82%, #141312);
|
||||
}
|
||||
|
||||
.wood {
|
||||
--element-color: var(--wood);
|
||||
background-color: color-mix(in srgb, var(--wood) 82%, #141312);
|
||||
}
|
||||
|
||||
.beats {
|
||||
color: var(--muted);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.battle-log {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
min-height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#battleLog {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#battleLog li {
|
||||
border-left: 3px solid rgba(214, 168, 73, 0.52);
|
||||
background: rgba(255, 244, 220, 0.05);
|
||||
padding: 9px 10px;
|
||||
color: var(--ink);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.collection {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.roster-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(255, 244, 220, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 18, 16, 0.72);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mini-art {
|
||||
aspect-ratio: 1 / 1.08;
|
||||
border: 1px solid rgba(255, 244, 220, 0.14);
|
||||
border-radius: 6px;
|
||||
background-color: #171514;
|
||||
background-image: var(--card-art-url), var(--sprite-art-url);
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: cover, 500% 400%;
|
||||
background-position: center, var(--sprite-x) var(--sprite-y);
|
||||
filter: hue-rotate(var(--art-hue, 0deg)) saturate(var(--art-sat, 1)) brightness(var(--art-bright, 1));
|
||||
}
|
||||
|
||||
.mini-card[data-art-ready="false"] .mini-art {
|
||||
background-image: var(--sprite-art-url);
|
||||
background-size: 500% 400%;
|
||||
background-position: var(--sprite-x) var(--sprite-y);
|
||||
}
|
||||
|
||||
.mini-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-meta {
|
||||
align-items: flex-start;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.mini-meta span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mini-card p,
|
||||
.mini-card small {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mini-card small {
|
||||
color: #e7d2ad;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.battlefield {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.command-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
grid-template-columns: minmax(110px, 0.42fr) minmax(220px, 1fr) minmax(180px, 0.9fr);
|
||||
grid-template-rows: auto auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.cycle-board,
|
||||
.battle-log {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.card-slot {
|
||||
min-height: 680px;
|
||||
}
|
||||
|
||||
.roster-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.battlefield,
|
||||
.command-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.meta-strip {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.command-panel {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.cycle-board,
|
||||
.battle-log {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.card-slot {
|
||||
min-height: 650px;
|
||||
}
|
||||
|
||||
.card-art {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.roster-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.brand {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
240
tools/export-card-assets.mjs
Normal file
@@ -0,0 +1,240 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const root = process.cwd();
|
||||
const mainPath = path.join(root, "main.js");
|
||||
const cardsRoot = path.join(root, "assets", "cards");
|
||||
const promptsDir = path.join(cardsRoot, "prompts");
|
||||
|
||||
function makeElement() {
|
||||
return {
|
||||
className: "",
|
||||
dataset: {},
|
||||
style: { setProperty() {} },
|
||||
children: [],
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
addEventListener() {},
|
||||
replaceChildren(...nodes) {
|
||||
this.children = nodes;
|
||||
},
|
||||
prepend(node) {
|
||||
this.children.unshift(node);
|
||||
},
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildDomStub() {
|
||||
const nodes = new Map();
|
||||
return {
|
||||
querySelector(selector) {
|
||||
if (!nodes.has(selector)) nodes.set(selector, makeElement());
|
||||
if (selector === "#emptyCardTemplate") {
|
||||
nodes.get(selector).content = { cloneNode: () => makeElement() };
|
||||
}
|
||||
return nodes.get(selector);
|
||||
},
|
||||
createElement: makeElement,
|
||||
};
|
||||
}
|
||||
|
||||
function damageSpec(stage) {
|
||||
const specs = [
|
||||
{
|
||||
label: "normal",
|
||||
visual: "intact armor and clothing, pristine battle-ready silhouette",
|
||||
poseNote: "confident ready stance",
|
||||
expression:
|
||||
"controlled confident expression, eyes focused, no distress",
|
||||
missingArmor:
|
||||
"no missing armor or clothing; all equipment is intact",
|
||||
camera:
|
||||
"front or three-quarter front view for clear character identity",
|
||||
},
|
||||
{
|
||||
label: "light-damage",
|
||||
visual:
|
||||
"light battle damage: scratched armor, torn cloak edges, scuffed gloves, one shoulder guard loosened, intact modest underlayers",
|
||||
poseNote: "still composed but reacting to an opening exchange",
|
||||
expression:
|
||||
"annoyed grimace, narrowed eyes, clenched jaw, clearly frustrated by the first hit",
|
||||
missingArmor:
|
||||
"minor loss of outer accessories only: torn glove edge, loosened shoulder strap, scuffed boot armor",
|
||||
camera:
|
||||
"three-quarter front view or slight side angle, enough to show both face and first armor damage",
|
||||
},
|
||||
{
|
||||
label: "medium-damage",
|
||||
visual:
|
||||
"moderate battle damage: cracked shoulder armor, torn outer sleeves, loosened belts, damaged protective plates, exposed forearms, one bare shoulder or torn shoulder covering, partially exposed abdomen or side through broken outer armor, intact modest underlayers",
|
||||
poseNote: "strained but still fighting with focus",
|
||||
expression:
|
||||
"painful frown, wet eyes, anger and disbelief, trying not to cry while staying in the fight; if hit in the abdomen, show a gasping open mouth and strained breathing",
|
||||
missingArmor:
|
||||
"visible loss around hands, forearms, one shoulder, boot guards, and side-abdomen armor; damage suggests repeated heavy impacts",
|
||||
camera:
|
||||
"side view or strong three-quarter side view, body twisted from impact so shoulder and abdomen damage are visible",
|
||||
},
|
||||
{
|
||||
label: "heavy-damage",
|
||||
visual:
|
||||
"heavy battle damage: broken outer armor, shredded mantle, fractured bracers, missing glove armor, damaged boots or bare foot/ankle on one side, exposed shoulders and abdomen/midriff through destroyed outer layers, intact fitted under-armor still covering intimate areas",
|
||||
poseNote: "wounded, guarded, barely keeping balance after a brutal exchange",
|
||||
expression:
|
||||
"tearful angry grimace, trembling lower lip, brows pinched, resentful eyes that show she hates losing; abdominal-hit reaction such as open mouth, gasping, hunched posture, and a small non-erotic saliva strand is allowed",
|
||||
missingArmor:
|
||||
"major loss around hands, feet, shoulders, abdomen, waist plates, and bracers; broken pieces scattered nearby",
|
||||
camera:
|
||||
"dramatic side view, rear three-quarter view, or over-the-shoulder angle while keeping face partially visible",
|
||||
},
|
||||
{
|
||||
label: "defeated",
|
||||
visual:
|
||||
"defeated state: kneeling, collapsed, or sitting back after battle, broken armor pieces around them, torn outer garment layers, missing hand and foot armor, bare shoulders or one exposed shoulder, exposed abdomen/midriff through destroyed outer armor, intact modest under-armor still covering intimate areas",
|
||||
poseNote: "battle-worn, emotionally overwhelmed, clearly defeated, no gore",
|
||||
expression:
|
||||
"frustrated tears, unfair-loss expression, clenched teeth or trembling open mouth, tear tracks, angry shame rather than calm dignity; defeated abdominal-hit pain reaction with open mouth, drool/saliva at the lip, and labored breathing is allowed when framed as combat pain",
|
||||
missingArmor:
|
||||
"the most intense equipment loss: shattered shoulder plates, missing bracers and glove armor, broken boot guards, torn waist and abdomen armor, scattered fragments",
|
||||
camera:
|
||||
"rear three-quarter, side-back, or low side view; show back/shoulder/cloak damage while keeping enough face profile to read pain",
|
||||
},
|
||||
];
|
||||
return specs[stage];
|
||||
}
|
||||
|
||||
function promptFor(character, stage) {
|
||||
const spec = damageSpec(stage);
|
||||
const elementName = character.affinity;
|
||||
const adultAppeal =
|
||||
"adult female fantasy pinup appeal: emphasize large breasts through armor/clothing silhouette, curvy mature body proportions, elegant sensual costume design";
|
||||
return [
|
||||
"Use case: stylized-concept",
|
||||
"Asset type: high-resolution individual trading card illustration",
|
||||
`Primary request: Create a unique full-card illustration for ${character.id} ${character.name}, damage stage ${stage} (${spec.label}).`,
|
||||
`Subject: adult ${character.gender} ${character.race}, ${character.body}, ${character.title}, ${character.role}, weapon ${character.weapon}.`,
|
||||
`Unique appearance DNA: ${character.appearance.faceShape}; ${character.appearance.eyeStyle}; ${character.appearance.noseStyle}; ${character.appearance.lipStyle}; ${character.appearance.skinTone}; ${character.appearance.hairStyle}; ${character.appearance.bodyDetail}; ${character.appearance.bustShape}; ${character.appearance.presence}. Make this face and body clearly different from other cards.`,
|
||||
`Personality and pose: ${character.personality}; ${character.pose}; ${spec.poseNote}.`,
|
||||
`Facial expression: ${spec.expression}.`,
|
||||
`Camera/view variation: ${spec.camera}. Across a character's five damage stages, avoid repeating the same straight-front pose; include front, side, and rear three-quarter views where appropriate.`,
|
||||
`Adult visual appeal: ${character.appealFocus}; ${adultAppeal}.`,
|
||||
`Scene/backdrop: fantasy duel arena with ${elementName} elemental atmosphere, no other characters.`,
|
||||
"Style/medium: polished semi-real painterly TCG key art, high-detail Korean/Asian fantasy mixed with global high-fantasy design, portrait card composition.",
|
||||
"Composition/framing: full body or knees-up character centered, strong silhouette, vertical card art, cinematic but readable.",
|
||||
`Damage state: ${spec.visual}.`,
|
||||
`Missing armor/clothing emphasis: ${spec.missingArmor}. Emphasize battle intensity through missing hand, foot, shoulder, and abdomen armor where appropriate.`,
|
||||
`Continuity: keep this character's face, body type, race, weapon, color motif, outfit silhouette, and elemental identity consistent across all five damage stages.`,
|
||||
"Constraints: all characters are adults; adult-oriented but non-explicit fantasy card art; sensual silhouette and body emphasis are allowed; pain expressions, tears, gasping, and small saliva/drool details must read as combat injury reactions, not erotic acts; no nudity; no visible nipples or genitals; no lingerie-only outfit; no sexual act; no pornographic pose; no gore; no text; no logo; no watermark.",
|
||||
"Avoid: childlike appearance, underage cues, explicit content, exposed intimate body parts, fetish framing, eroticized pain framing, malformed hands, extra limbs, cropped head, UI elements.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(mainPath, "utf8");
|
||||
const context = {
|
||||
document: buildDomStub(),
|
||||
window: { setInterval() {}, clearInterval() {} },
|
||||
fetch: async () => ({ ok: false }),
|
||||
console,
|
||||
};
|
||||
const exportCode = `${source}
|
||||
globalThis.__cardExport = {
|
||||
damageStages: DAMAGE_ART_STAGES,
|
||||
characters: CHARACTERS.map((character) => ({
|
||||
id: character.id,
|
||||
index: character.index,
|
||||
name: character.name,
|
||||
gender: character.gender,
|
||||
race: character.race,
|
||||
body: character.body,
|
||||
affinity: character.affinity,
|
||||
stars: character.stars,
|
||||
title: character.title,
|
||||
role: character.role,
|
||||
weapon: character.weapon,
|
||||
personality: character.personality,
|
||||
pose: character.pose,
|
||||
origin: character.origin,
|
||||
damageProfile: character.damageProfile,
|
||||
appealFocus: character.appealFocus,
|
||||
appearance: character.appearance,
|
||||
stats: character.stats
|
||||
}))
|
||||
};`;
|
||||
|
||||
vm.runInNewContext(exportCode, context);
|
||||
|
||||
const exported = context.__cardExport;
|
||||
fs.mkdirSync(cardsRoot, { recursive: true });
|
||||
fs.mkdirSync(promptsDir, { recursive: true });
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
artRoot: "assets/cards",
|
||||
damageStages: Array.from({ length: exported.damageStages }, (_, stage) => ({
|
||||
stage,
|
||||
...damageSpec(stage),
|
||||
})),
|
||||
expectedImageCount: exported.characters.length * exported.damageStages,
|
||||
imageSizeTarget: "portrait high resolution, 2048x3072 or better for final production",
|
||||
safetyStyle: "adult-oriented non-explicit fantasy pinup combat damage; emphasized mature silhouettes, armor/clothing damage without nudity, visible intimate body parts, sexual acts, or gore",
|
||||
characters: exported.characters.map((character) => ({
|
||||
...character,
|
||||
art: Array.from({ length: exported.damageStages }, (_, stage) => ({
|
||||
damageStage: stage,
|
||||
path: `assets/cards/${character.id}/damage-${stage}.png`,
|
||||
promptId: `${character.id}-D${stage}`,
|
||||
status: "needed",
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const promptLines = [];
|
||||
for (const character of exported.characters) {
|
||||
for (let stage = 0; stage < exported.damageStages; stage += 1) {
|
||||
promptLines.push(
|
||||
JSON.stringify({
|
||||
promptId: `${character.id}-D${stage}`,
|
||||
characterId: character.id,
|
||||
damageStage: stage,
|
||||
outputPath: `assets/cards/${character.id}/damage-${stage}.png`,
|
||||
prompt: promptFor(character, stage),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const availablePath = path.join(cardsRoot, "available-assets.json");
|
||||
if (!fs.existsSync(availablePath)) {
|
||||
fs.writeFileSync(
|
||||
availablePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
assets: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(cardsRoot, "card-manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
||||
fs.writeFileSync(path.join(promptsDir, "card-image-prompts.jsonl"), `${promptLines.join("\n")}\n`, "utf8");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
characters: exported.characters.length,
|
||||
damageStages: exported.damageStages,
|
||||
expectedImageCount: manifest.expectedImageCount,
|
||||
manifest: "assets/cards/card-manifest.json",
|
||||
prompts: "assets/cards/prompts/card-image-prompts.jsonl",
|
||||
available: "assets/cards/available-assets.json",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
37
tools/scan-card-assets.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const cardsRoot = path.join(root, "assets", "cards");
|
||||
const manifestPath = path.join(cardsRoot, "card-manifest.json");
|
||||
const availablePath = path.join(cardsRoot, "available-assets.json");
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const assets = [];
|
||||
|
||||
for (const character of manifest.characters) {
|
||||
for (const art of character.art) {
|
||||
const absolute = path.join(root, art.path);
|
||||
if (!fs.existsSync(absolute)) continue;
|
||||
const stat = fs.statSync(absolute);
|
||||
assets.push({
|
||||
characterId: character.id,
|
||||
damageStage: art.damageStage,
|
||||
path: art.path.replaceAll("\\", "/"),
|
||||
bytes: stat.size,
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
expectedImageCount: manifest.expectedImageCount,
|
||||
completedImageCount: assets.length,
|
||||
completedCharacterCount: new Set(assets.map((asset) => asset.characterId)).size,
|
||||
assets,
|
||||
};
|
||||
|
||||
fs.writeFileSync(availablePath, JSON.stringify(payload, null, 2), "utf8");
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||