837 lines
65 KiB
JavaScript
837 lines
65 KiB
JavaScript
const state = {
|
||
data: null,
|
||
view: "firstplay",
|
||
locale: "en",
|
||
screenId: "SYS-005",
|
||
screenGroup: "ALL",
|
||
screenVariant: "primary",
|
||
flowIndex: 1,
|
||
regionIndex: 0,
|
||
catalogType: "professions",
|
||
catalogFilter: "all",
|
||
catalogQuery: "",
|
||
openingSelected: [],
|
||
partySwapped: false,
|
||
battlePaused: false,
|
||
battleSpeed: 1,
|
||
settings: {
|
||
contrast: false,
|
||
reducedMotion: false,
|
||
captions: true,
|
||
lowPower: false,
|
||
textScale: "100",
|
||
},
|
||
};
|
||
|
||
const copy = {
|
||
en: {
|
||
brandSubtitle: "Final design prototype",
|
||
navFirst: "First play",
|
||
navScreens: "Screen atlas",
|
||
navRegions: "Regions",
|
||
navCatalogs: "Catalogs",
|
||
navReview: "Review gates",
|
||
runtimeFrozen: "Runtime development frozen",
|
||
conceptBadge: "Concept direction",
|
||
firstplay: "First play",
|
||
screens: "Screen atlas",
|
||
regions: "Regions",
|
||
catalogs: "Catalogs",
|
||
review: "Review gates",
|
||
prev: "Previous",
|
||
next: "Next decision",
|
||
back: "Back",
|
||
inspect: "Inspect",
|
||
compare: "Compare",
|
||
concept: "Concept direction - interface authored in HTML",
|
||
saved: "Saved",
|
||
company: "Company",
|
||
capacity: "Line capacity",
|
||
protected: "Protected rear",
|
||
exposed: "Exposed front",
|
||
selected: "Selected",
|
||
select: "Select",
|
||
reviewCompany: "Review Company",
|
||
createRun: "Create Run",
|
||
openMarket: "Open First Market",
|
||
noCoin: "No Coin cost",
|
||
notRetained: "Not retained",
|
||
loading: "Loading",
|
||
retry: "Retry same state",
|
||
returnSafe: "Return safely",
|
||
},
|
||
zh_CN: {
|
||
brandSubtitle: "最终设计原型",
|
||
navFirst: "首次游玩",
|
||
navScreens: "页面图谱",
|
||
navRegions: "八大区域",
|
||
navCatalogs: "完整图鉴",
|
||
navReview: "审核闸门",
|
||
runtimeFrozen: "运行时开发仍冻结",
|
||
conceptBadge: "概念方向",
|
||
firstplay: "首次游玩",
|
||
screens: "页面图谱",
|
||
regions: "八大区域",
|
||
catalogs: "完整图鉴",
|
||
review: "审核闸门",
|
||
prev: "上一步",
|
||
next: "下一决策",
|
||
back: "返回",
|
||
inspect: "查看",
|
||
compare: "比较",
|
||
concept: "概念方向 - 界面由 HTML 独立绘制",
|
||
saved: "已保存",
|
||
company: "队伍",
|
||
capacity: "出战容量",
|
||
protected: "受保护后排",
|
||
exposed: "暴露前排",
|
||
selected: "已选择",
|
||
select: "选择",
|
||
reviewCompany: "审核队伍",
|
||
createRun: "创建远征",
|
||
openMarket: "进入首个集市",
|
||
noCoin: "不消耗金币",
|
||
notRetained: "不会保留",
|
||
loading: "正在载入",
|
||
retry: "重试同一状态",
|
||
returnSafe: "安全返回",
|
||
},
|
||
};
|
||
|
||
const screenNamesZh = {
|
||
"SYS-001": "启动与校验", "SYS-002": "首次启动设置", "SYS-003": "标题", "SYS-004": "远征档案",
|
||
"SYS-005": "新远征设置", "SYS-006": "继续前审核", "SYS-007": "存档恢复", "SYS-008": "制作与数据说明",
|
||
"HUB-001": "公会桌面", "HUB-002": "威胁预报", "SHP-001": "集市", "SHP-002": "招募审核",
|
||
"SHP-003": "装备报价审核", "REC-001": "成员名册", "REC-002": "成员详情", "REC-003": "遣散审核",
|
||
"EQP-001": "装备工台", "EQP-002": "装备比较", "ART-001": "神器账册", "PTY-001": "队伍横列",
|
||
"PTY-002": "出战准备审核", "RSK-001": "风险看板", "RSK-002": "风险承诺", "BAT-001": "战斗开场",
|
||
"BAT-002": "自动战斗", "BAT-003": "角色与事件查看", "BAT-004": "暂停与速度", "BAT-005": "撤退审核",
|
||
"BAT-006": "战斗恢复审核", "OUT-001": "战果与诊断", "OUT-002": "死亡与损失", "OUT-003": "奖励选择",
|
||
"OUT-004": "恢复选择", "OUT-005": "远征结束总结", "OUT-006": "放弃远征审核", "PRO-001": "本轮成长",
|
||
"PRO-002": "永久成长", "PRO-003": "记录与图鉴", "PST-001": "终局挑战", "PST-002": "终局远征审核",
|
||
"PST-003": "终局结果", "SET-001": "设置", "SET-002": "显示与文字", "SET-003": "动效与闪烁",
|
||
"SET-004": "音频与字幕", "SET-005": "控制方式", "SET-006": "语言与地区", "SET-007": "玩法信息",
|
||
"SET-008": "存档与数据", "SET-009": "重置与删除",
|
||
};
|
||
|
||
const flowSteps = [
|
||
["SYS-005", "contract", "Contract and difficulty", "契约与难度"],
|
||
["SYS-005", "offers0", "Four stable offers - 0/2", "四个固定候选 - 0/2"],
|
||
["SYS-005", "offers1", "Read one tradeoff - 1/2", "读懂一个取舍 - 1/2"],
|
||
["SYS-005", "offers2", "Choose exactly two - 2/2", "恰好选择两人 - 2/2"],
|
||
["SYS-005", "review", "Creation review", "创建前审核"],
|
||
["SYS-005", "pending", "Atomic creation", "原子化创建"],
|
||
["SYS-005", "receipt", "Run receipt", "远征回执"],
|
||
["SHP-001", "company2", "First Market - Company 2/4", "首个集市 - 队伍 2/4"],
|
||
["SHP-002", "hire1", "First hire review", "第一次招募审核"],
|
||
["SHP-001", "company3", "Company 3/4", "队伍 3/4"],
|
||
["SHP-002", "hire2", "Second hire review", "第二次招募审核"],
|
||
["SHP-001", "company4", "Capacity filled - 4/4", "容量填满 - 4/4"],
|
||
["PTY-001", "reorder", "Order the horizontal line", "调整横向队列"],
|
||
["RSK-001", "compare", "Compare three risks", "比较三种风险"],
|
||
["RSK-002", "commit", "Commit party and exposure", "确认队伍与风险"],
|
||
["BAT-001", "opening", "Battle opening", "战斗开场"],
|
||
["BAT-002", "live", "Automatic resolution", "自动执行"],
|
||
["BAT-003", "inspect", "Inspect first cause", "查看首个因果"],
|
||
["OUT-001", "diagnosis", "Read the divergence", "阅读分歧点"],
|
||
["OUT-003", "reward", "Choose a fixed reward", "选择固定奖励"],
|
||
["EQP-002", "adapt", "Compare one adaptation", "比较一次调整"],
|
||
["OUT-002", "casualty", "Understand permanent loss", "理解永久损失"],
|
||
["OUT-004", "recovery", "Purchase recovery", "购买恢复方案"],
|
||
];
|
||
|
||
const screenGroups = {
|
||
ALL: "All 50", SYS: "System", HUB: "Guild", SHP: "Market", REC: "Roster",
|
||
EQP: "Equipment", ART: "Artifacts", PTY: "Party", RSK: "Risk", BAT: "Battle",
|
||
OUT: "Outcome", PRO: "Progress", PST: "Postgame", SET: "Settings",
|
||
};
|
||
|
||
const sceneImages = [
|
||
"assets/web/ch_ch01.webp", "assets/web/ch_ch02.webp", "assets/web/ch_ch03.webp", "assets/web/ch_ch04.webp",
|
||
"assets/web/ch_ch05.webp", "assets/web/ch_ch06.webp", "assets/web/ch_ch07.webp", "assets/web/ch_ch08.webp",
|
||
];
|
||
|
||
const generatedOffers = [
|
||
{ id: "OFF-01", name: "Elian Rook", profession: "Hearthguard", trait: "Narrow Guard", read: "Intercepts a neighbor / slower steps", mark: "HG" },
|
||
{ id: "OFF-02", name: "Nia Orrow", profession: "Farstring", trait: "Patient Start", read: "Rear pressure / delayed first basic", mark: "FS" },
|
||
{ id: "OFF-03", name: "Tomas Vale", profession: "Wayrunner", trait: "Open Stance", read: "Reorders safely / briefly Exposed", mark: "WR" },
|
||
{ id: "OFF-04", name: "Sable Kest", profession: "Kindhand", trait: "Shared Cup", read: "Restores a neighbor / weaker self-heal", mark: "KH" },
|
||
];
|
||
|
||
const stageDetails = {
|
||
"SYS-001": ["Verifying local rules", "Content revision 69f671", "Save lineage healthy"],
|
||
"SYS-002": ["Language: English", "Text: 115%", "Captions: On"],
|
||
"SYS-003": ["Continue safely", "New Run", "Records"],
|
||
"SYS-004": ["Slot 01 - Market, Round 3", "Slot 02 - No run", "Local save verified"],
|
||
"SYS-006": ["Last receipt: equipment return", "Next: Recovery", "Snapshot: 00:42.6"],
|
||
"SYS-007": ["Local lineage - healthy", "Backup - 12 min older", "Conflict - review required"],
|
||
"SYS-008": ["Original design and media", "Offline core play", "No third-party commercial assets"],
|
||
"HUB-001": ["Next pressure: projectile screen", "Company ready 4/4", "Last receipt: reward claimed"],
|
||
"HUB-002": ["Pane Nibbler - Guard pressure", "Gust Finch - rear projectile", "Confidence: exact"],
|
||
"SHP-003": ["Stepback Soles", "After first direct hit: step rear", "Tradeoff: next basic waits"],
|
||
"REC-001": ["Deployed 4", "Reserve 1", "Cumulative hires 6"],
|
||
"REC-002": ["Nia Orrow - Farstring", "Trait: Patient Start", "Survived 4 battles"],
|
||
"REC-003": ["Equipment returns", "No Replacement Credit", "Leaves 3 living recruits"],
|
||
"ART-001": ["Open Ledger - active", "Narrow Vanguard - conflict", "2 shared rules inspected"],
|
||
"PTY-002": ["Order frozen: 3 > 2 > 1 > 0", "Rear protected", "Known pressure covered"],
|
||
"RSK-002": ["Threat: projectile screen", "Reward: equipment choice", "Retreat: one Coin tier"],
|
||
"BAT-004": ["Paused at 00:08.4", "Speed 1x / 2x / 4x", "Event log available"],
|
||
"BAT-005": ["Retreat resolves after current event", "No fallen recruits", "Return to Market"],
|
||
"BAT-006": ["Verified time 00:08.4", "Next event: Pane Bite", "Resume static at 1x"],
|
||
"OUT-005": ["Contract clear", "Renown +4", "Discoveries 7"],
|
||
"OUT-006": ["Closes this run", "Coin and Credit cleared", "History retained"],
|
||
"PRO-001": ["Capacity milestone 4 > 5", "Hearthguard branch visible", "One choice pending"],
|
||
"PRO-002": ["Profession breadth 18/42", "Region routes 3/8", "No permanent power"],
|
||
"PST-001": ["Profession Mastery", "Catalog Completion", "Oath Contracts"],
|
||
"PST-002": ["Oath: Narrow Company", "Difficulty: Standard", "Ordinary New Run remains"],
|
||
"PST-003": ["Challenge cleared", "Rules that mattered: 2", "Record receipt applied"],
|
||
};
|
||
|
||
const settingRows = {
|
||
"SET-001": [["Display and text", "115%"], ["Motion and power", "Reduced"], ["Audio and captions", "Captions on"], ["Controls", "Touch + keyboard"]],
|
||
"SET-002": [["UI and text scale", "100 / 115 / 130"], ["Safe area", "Reference"], ["High contrast", "Off"], ["Color-independent cues", "On"]],
|
||
"SET-003": [["Reduced motion", "On"], ["Reduced flashes", "On"], ["Camera shake", "25%"], ["Low-power mode", "Off"]],
|
||
"SET-004": [["Master", "80%"], ["Music", "65%"], ["Effects", "80%"], ["Captions", "Critical + full log"]],
|
||
"SET-005": [["Inspect", "I / South"], ["Pause", "Space / Menu"], ["Reorder", "Select then destination"], ["Hold timing", "600 ms"]],
|
||
"SET-006": [["Language", "English / 简体中文"], ["Numbers", "Locale format"], ["Text preview", "130%"], ["Reload", "No run change"]],
|
||
"SET-007": [["Forecast detail", "Full"], ["Tutorial reminders", "On"], ["Battle log density", "Detailed"], ["Default speed", "1x"]],
|
||
"SET-008": [["Local save", "Healthy"], ["Backup", "12 min ago"], ["Offline", "Core play available"], ["Diagnostics", "Export ready"]],
|
||
"SET-009": [["Delete current run", "Review"], ["Reset settings", "Review"], ["Reset profile", "Locked"], ["Diagnostics", "Clear"]],
|
||
};
|
||
|
||
const $ = (selector, root = document) => root.querySelector(selector);
|
||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||
const t = (key) => copy[state.locale][key] || copy.en[key] || key;
|
||
|
||
function escapeHtml(value = "") {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function truncate(value, length = 150) {
|
||
const text = String(value || "");
|
||
return text.length > length ? `${text.slice(0, length - 1)}...` : text;
|
||
}
|
||
|
||
function screenName(screen) {
|
||
return state.locale === "zh_CN" ? screenNamesZh[screen.id] || screen.name : screen.name;
|
||
}
|
||
|
||
function getScreen(id) {
|
||
return state.data.screens.find((screen) => screen.id === id);
|
||
}
|
||
|
||
function flowLabel(step) {
|
||
return step[state.locale === "zh_CN" ? 3 : 2];
|
||
}
|
||
|
||
function iconButton(symbol, label, attrs = "") {
|
||
return `<button class="stage-icon" type="button" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}" ${attrs}>${symbol}</button>`;
|
||
}
|
||
|
||
function stageArt(index = 0) {
|
||
return `<img class="stage-art" src="${sceneImages[index % sceneImages.length]}" alt="" />`;
|
||
}
|
||
|
||
function conceptLabel() {
|
||
return `<span class="concept-label">${escapeHtml(t("concept"))}</span>`;
|
||
}
|
||
|
||
function stageHeader(screen, context = "Windglass Verge", resources = true) {
|
||
return `<div class="game-hud">
|
||
<div class="hud-left">${iconButton("‹", t("back"))}<span class="screen-stamp">${escapeHtml(screen.id)}</span><strong>${escapeHtml(context)}</strong></div>
|
||
<div class="hud-right">${resources ? `<span class="resource"><b>214</b> Coin</span><span class="resource"><b>0</b> Credit</span>` : ""}<span class="save-state">◆ ${escapeHtml(t("saved"))}</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
function actionDock(primary, secondary = t("inspect"), attrs = "") {
|
||
return `<div class="action-dock"><button class="stage-secondary" type="button">${escapeHtml(secondary)}</button><button class="stage-primary" type="button" ${attrs}>${escapeHtml(primary)}</button></div>`;
|
||
}
|
||
|
||
function frame(screen, body, options = {}) {
|
||
const classes = ["game-stage", `family-${screen.id.split("-")[0].toLowerCase()}`, options.className || ""].filter(Boolean).join(" ");
|
||
const art = options.art === false ? "" : stageArt(options.region || 0);
|
||
const header = options.header === false ? "" : stageHeader(screen, options.context, options.resources !== false);
|
||
return `<section class="${classes}" data-stage-id="${escapeHtml(screen.id)}" data-variant="${escapeHtml(options.variant || "primary")}" aria-label="${escapeHtml(screenName(screen))}">
|
||
${art}<div class="stage-wash"></div>${header}<div class="stage-body">${body}</div>${conceptLabel()}
|
||
</section>`;
|
||
}
|
||
|
||
function renderOpening(screen, variant = "offers2") {
|
||
const oneSelected = state.openingSelected.length === 1 ? state.openingSelected : ["OFF-02"];
|
||
const twoSelected = state.openingSelected.length === 2 ? state.openingSelected : ["OFF-02", "OFF-04"];
|
||
const selectionByVariant = { offers0: state.openingSelected, offers1: oneSelected, offers2: twoSelected, review: twoSelected, pending: twoSelected, receipt: twoSelected };
|
||
const selected = selectionByVariant[variant] || state.openingSelected;
|
||
if (variant === "contract") {
|
||
const body = `<div class="contract-composition">
|
||
<div class="region-signal"><span>RG-01</span><h2>Windglass Verge</h2><p>Contract 01 - chart the suspended pane crossing.</p></div>
|
||
<div class="contract-sheet"><span class="stage-kicker">NEW CONTRACT</span><h3>Trade Route: First Crossing</h3>
|
||
<dl><div><dt>Known pressure</dt><dd>Push + projectile screen</dd></div><div><dt>Boss rule</dt><dd>Rotating damage-family panes</dd></div><div><dt>Reward breadth</dt><dd>Base professions + opening tools</dd></div><div><dt>Difficulty</dt><dd>Standard - full systems</dd></div></dl>
|
||
<button class="stage-primary" type="button" data-flow-next>Load Provisional Offers</button></div>
|
||
</div>`;
|
||
return frame(screen, body, { variant, resources: false });
|
||
}
|
||
if (variant === "review" || variant === "pending" || variant === "receipt") {
|
||
const chosen = generatedOffers.filter((offer) => selected.includes(offer.id));
|
||
const unchosen = generatedOffers.filter((offer) => !selected.includes(offer.id));
|
||
const stateBlock = variant === "pending"
|
||
? `<div class="operation-state"><span class="operation-spinner"></span><strong>Creating run</strong><small>Operation RUN-CREATE-014</small></div>`
|
||
: variant === "receipt"
|
||
? `<div class="receipt-state"><b>✓ RUN-CREATE-014</b><strong>${t("company")} 2 <i>/</i> ${t("capacity")} 4</strong><small>The two unchosen offers were not retained.</small></div>`
|
||
: `<div class="creation-ledger"><span>No Coin transaction</span><b>Slot 01 · Standard</b><small>Two unchosen stable IDs will not be retained.</small></div>`;
|
||
const body = `<div class="creation-review"><span class="stage-kicker">${variant === "receipt" ? "RUN RECEIPT" : "COMPANY REVIEW"}</span>
|
||
<div class="chosen-pair">${chosen.map((offer) => recruitMarker(offer, true)).join("")}</div>
|
||
<div class="unchosen-strip">${unchosen.map((offer) => `<span><b>${offer.id}</b> ${offer.name}<em>${t("notRetained")}</em></span>`).join("")}</div>
|
||
${stateBlock}
|
||
<div class="review-actions"><button class="stage-secondary" type="button" data-flow-prev>${variant === "review" ? "Revise Recruits" : t("back")}</button>${variant !== "pending" ? `<button class="stage-primary" type="button" data-flow-next>${variant === "receipt" ? t("openMarket") : t("createRun")}</button>` : ""}</div>
|
||
</div>`;
|
||
return frame(screen, body, { variant, resources: false });
|
||
}
|
||
const body = `<div class="opening-composition">
|
||
<div class="opening-title"><span class="stage-kicker">PROVISIONAL COMPANY</span><h2>Choose two generated recruits</h2><p>Offer set WIND-01-A remains stable until run creation.</p></div>
|
||
<div class="offer-grid">${generatedOffers.map((offer) => recruitOffer(offer, selected.includes(offer.id))).join("")}</div>
|
||
<div class="opening-footer"><div class="selection-count"><span>${selected.length === 2 ? t("selected") : "Choose two"}</span><strong>${selected.length}/2</strong></div><button class="stage-secondary" type="button">${t("compare")}</button><button class="stage-primary" type="button" data-flow-next ${selected.length === 2 ? "" : "disabled"}>${t("reviewCompany")}</button></div>
|
||
</div>`;
|
||
return frame(screen, body, { variant, resources: false });
|
||
}
|
||
|
||
function recruitMarker(offer, selected = false) {
|
||
return `<article class="recruit-marker ${selected ? "is-selected" : ""}"><span class="recruit-silhouette">${offer.mark}</span><div><small>${offer.id}</small><strong>${offer.name}</strong><em>${offer.profession}</em></div></article>`;
|
||
}
|
||
|
||
function recruitOffer(offer, selected) {
|
||
return `<article class="recruit-offer ${selected ? "is-selected" : ""}">
|
||
<div class="offer-figure"><span>${offer.mark}</span><small>${offer.id}</small></div>
|
||
<div class="offer-copy"><strong>${offer.name}</strong><span class="profession-seal">${offer.profession}</span><p><b>${offer.trait}</b> - ${offer.read}</p></div>
|
||
<div class="offer-actions"><span>${t("noCoin")}</span><button type="button" data-offer-id="${offer.id}" aria-pressed="${selected}">${selected ? `✓ ${t("selected")}` : t("select")}</button>${iconButton("i", `${t("inspect")} ${offer.name}`)}</div>
|
||
</article>`;
|
||
}
|
||
|
||
function companyCountForVariant(variant) {
|
||
if (variant === "company2") return 2;
|
||
if (variant === "company3") return 3;
|
||
return 4;
|
||
}
|
||
|
||
function renderMarket(screen, variant) {
|
||
const count = companyCountForVariant(variant);
|
||
const isRecruit = screen.id === "SHP-002";
|
||
const isItem = screen.id === "SHP-003";
|
||
if (isRecruit) {
|
||
const offer = variant === "hire2" ? generatedOffers[2] : generatedOffers[0];
|
||
const body = `<div class="market-review">
|
||
<div class="review-figure">${recruitMarker(offer, true)}<span class="trait-glyph">⬡</span></div>
|
||
<div class="review-facts"><span class="stage-kicker">RECRUIT REVIEW</span><h2>${offer.name}</h2><h3>${offer.profession} · ${offer.trait}</h3>
|
||
<div class="stat-radar"><span>VIT <b>A</b></span><span>PACE <b>C</b></span><span>REACH <b>${offer.profession === "Hearthguard" ? "1" : "2"}</b></span><span>GUARD <b>B</b></span></div>
|
||
<p>${offer.read}. Generated identity, exactly one Trait, no authored personal arc.</p></div>
|
||
<div class="party-impact"><span>PARTY IMPACT</span><strong>${t("company")} ${count} → ${Math.min(4, count + 1)}</strong><p>${offer.profession === "Hearthguard" ? "Adds a visible front intercept." : "Adds position movement and target change."}</p><small>Price 46 Coin · Operation HIRE-${variant === "hire2" ? "002" : "001"}</small></div>
|
||
${actionDock("Recruit - 46 Coin", t("compare"), "data-flow-next")}
|
||
</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
if (isItem) {
|
||
const body = `<div class="item-review"><div class="item-object">⇣<small>EQ-001</small></div><div><span class="stage-kicker">ITEM OFFER</span><h2>Stepback Soles</h2><p>After the first direct hit, step one position rearward if legal.</p><div class="delta-row"><span>Target exposure</span><b>Front → Center</b></div><div class="delta-row danger"><span>Next basic</span><b>Wait +1 tick</b></div></div><aside><b>Eligible</b><span>Nia · Tomas · Sable</span><b>Conflict</b><span>Full rear makes trigger dormant</span></aside>${actionDock("Buy - 32 Coin", t("inspect"))}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
const offers = [generatedOffers[0], generatedOffers[2], generatedOffers[3]];
|
||
const body = `<div class="market-composition">
|
||
<div class="market-heading"><div><span class="stage-kicker">FIRST MARKET</span><h2>Build capability before choosing risk</h2></div><div class="company-meter"><span>${t("company")}</span><strong>${count}</strong><i>/</i><span>${t("capacity")}</span><strong>4</strong></div></div>
|
||
<div class="market-tabs"><button class="is-active">Recruits · 3</button><button>Equipment · 4</button><button>Risks · 3</button></div>
|
||
<div class="market-surface"><div class="market-list">${offers.map((offer, index) => `<button class="market-row ${index === 0 ? "is-focused" : ""}" type="button"><span class="row-avatar">${offer.mark}</span><div><small>${offer.id}</small><strong>${offer.name}</strong><em>${offer.profession} · ${offer.trait}</em></div><b>${index < 2 ? "46" : "58"}</b></button>`).join("")}</div>
|
||
<aside class="market-inspect"><span class="stage-kicker">AFFORDABLE BASE OFFER</span><h3>${offers[0].name}</h3><p>${offers[0].read}</p><div class="fit-read"><span>Front hold</span><b>Needed</b></div><div class="fit-read"><span>Trait cost</span><b>Visible</b></div><button class="stage-primary" type="button" data-flow-next>${count < 4 ? "Review Recruit" : "Open Party Line"}</button></aside></div>
|
||
<div class="market-footer"><span>Refresh 18 Coin · next 24</span><button class="stage-secondary">Lock 0/2</button></div>
|
||
</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function partyActors() {
|
||
const original = [
|
||
["3", "Nia", "FS", "Rear"], ["2", "Sable", "KH", "Protected"], ["1", "Tomas", "WR", "Center"], ["0", "Elian", "HG", "Exposed"],
|
||
];
|
||
const swapped = [original[0], original[2], original[1], original[3]];
|
||
return (state.partySwapped ? swapped : original).map(([position, name, mark, status], index) => `<button class="party-actor ${position === "0" ? "is-front" : ""}" type="button" data-party-index="${index}"><span class="actor-figure">${mark}</span><b>${position}</b><strong>${name}</strong><em>${status}</em></button>`).join("");
|
||
}
|
||
|
||
function renderPreparation(screen, variant) {
|
||
if (screen.id.startsWith("EQP")) {
|
||
const comparison = screen.id === "EQP-002";
|
||
const body = `<div class="equipment-composition ${comparison ? "is-comparison" : ""}">
|
||
<div class="equipment-list"><span class="stage-kicker">ELIGIBLE INVENTORY</span>${["Stepback Soles", "Frontpin Greaves", "Rescue Loop", "Open-Line Cord"].map((name, index) => `<button class="equipment-row ${index === 0 ? "is-focused" : ""}"><span>${["⇣", "⊓", "⌁", "—"][index]}</span><div><strong>${name}</strong><small>Behavior class B</small></div></button>`).join("")}</div>
|
||
<div class="equipment-wearer"><span class="large-figure">FS</span><strong>Nia Orrow</strong><small>Farstring · position 3</small><div class="equipped-slot">Current: Quiet Rearplate</div></div>
|
||
<div class="equipment-delta"><span class="stage-kicker">BEHAVIOR DELTA</span><h2>${comparison ? "Quiet Rearplate → Stepback Soles" : "Stepback Soles"}</h2><div class="delta-row"><span>After first hit</span><b>Step rear</b></div><div class="delta-row"><span>Target relation</span><b>Front → Center</b></div><div class="delta-row danger"><span>Next basic</span><b>Wait +1</b></div><p>At full rear the trigger is dormant. Apply remains reversible until readiness commit.</p><button class="stage-primary" type="button" ${variant === "adapt" ? "data-flow-next" : ""}>Apply candidate</button></div>
|
||
</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
const ready = screen.id === "PTY-002";
|
||
const body = `<div class="party-composition ${ready ? "is-ready" : ""}">
|
||
<div class="party-title"><span class="stage-kicker">${ready ? "FROZEN PARTY SNAPSHOT" : "ORDERED PARTY LINE"}</span><h2>${t("protected")} <i>→</i> ${t("exposed")}</h2><p>Position 0 is always the rightmost front. The line is never mirrored by locale.</p></div>
|
||
<div class="relation-layer"><span class="relation protection">Sable protects 0</span><span class="relation reach">Nia reaches enemy rear</span></div>
|
||
<div class="party-cord"><span class="cord-label rear">${t("protected")}</span>${partyActors()}<span class="cord-label front">${t("exposed")}</span></div>
|
||
<aside class="party-read"><strong>${ready ? "Plan accepted" : "Changed relations"}</strong><span>Elian · first legal target</span><span>Nia · projectile screen risk</span><span>Reserve · 1 living recruit</span></aside>
|
||
<div class="party-tools">${iconButton("↶", "Undo")}${iconButton("↺", "Reset")}<button class="stage-secondary" type="button" data-party-swap>${ready ? "Inspect order" : "Swap positions 1 / 2"}</button><button class="stage-primary" type="button" data-flow-next>${ready ? "Accept preparation" : "Save Order"}</button></div>
|
||
</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderRisk(screen, variant) {
|
||
const review = screen.id === "RSK-002";
|
||
const risks = [
|
||
["Steady Crossing", "Pane Nibbler + Gust Finch", "Equipment", "Low"],
|
||
["Shattered Terrace", "Prism Ox + rear pressure", "Artifact", "Medium"],
|
||
["Bellward Detour", "Unknown third identity", "Profession", "High"],
|
||
];
|
||
const body = review ? `<div class="risk-review"><span class="stage-kicker">RISK COMMITMENT</span><h2>Steady Crossing</h2><div class="threat-band"><span>Known threat<b>Projectile screen</b></span><span>Entry cost<b>0 Coin</b></span><span>Reward class<b>Equipment</b></span><span>Retreat<b>One Coin tier</b></span></div><div class="mini-party-line">${partyActors()}</div><p>The accepted order is frozen with the risk receipt. Selection alone changed nothing.</p>${actionDock("Begin Battle", "Return to Risk Board", "data-flow-next")}</div>` : `<div class="risk-board"><div class="risk-heading"><span class="stage-kicker">THREE BOUNDED OFFERS</span><h2>Choose pressure, not a hidden result</h2></div><div class="risk-options">${risks.map((risk, index) => `<button class="risk-option ${index === 0 ? "is-selected" : ""}" type="button"><span>0${index + 1}</span><strong>${risk[0]}</strong><small>${risk[1]}</small><div><em>${risk[2]}</em><b>${risk[3]} exposure</b></div></button>`).join("")}</div><div class="risk-compare"><span>Coverage</span><b>Front hold ✓</b><b>Rear screen △</b><b>Recovery 214 Coin</b><button class="stage-primary" type="button" data-flow-next>Review Steady Crossing</button></div></div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function battleLine() {
|
||
return `<div class="battle-line"><div class="allied-line"><span class="edge-label">${t("protected")}</span>${partyActors()}<span class="edge-label">${t("exposed")}</span></div><div class="action-gap"><span class="forecast-arrow">Pane Bite · 0.8s → Elian</span></div><div class="enemy-line"><span class="enemy-actor elite">OX<small>Prism screen</small></span><span class="enemy-actor">PN<small>Guard chip</small></span><span class="enemy-actor">GF<small>Rear shot</small></span></div></div>`;
|
||
}
|
||
|
||
function renderBattle(screen, variant) {
|
||
const id = screen.id;
|
||
if (["BAT-004", "BAT-005", "BAT-006"].includes(id)) {
|
||
const title = id === "BAT-004" ? "Observation paused" : id === "BAT-005" ? "Retreat consequence" : "Resume verified snapshot";
|
||
const primary = id === "BAT-004" ? "Resume battle" : id === "BAT-005" ? "Begin retreat" : "Resume at 1x";
|
||
const rows = stageDetails[id];
|
||
const body = `<div class="battle-static">${battleLine()}<div class="battle-sheet"><span class="stage-kicker">${id}</span><h2>${title}</h2>${rows.map((row) => `<div class="sheet-row">${row}</div>`).join("")}<div class="speed-control"><button class="is-active">1x</button><button>2x</button><button>4x</button></div>${actionDock(primary, id === "BAT-005" ? "Cancel" : "Event log")}</div></div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
const inspect = id === "BAT-003";
|
||
const opening = id === "BAT-001";
|
||
const body = `<div class="battle-composition ${state.battlePaused ? "is-paused" : ""}">
|
||
<div class="battle-objective"><span class="stage-kicker">${opening ? "BATTLE OPENING" : "AUTOMATIC BATTLE"}</span><h2>${opening ? "Break the rotating pane screen" : "Urgent: Guard chip reaches Sable"}</h2><p>${opening ? "Player line faces right. Position 0 receives the first legal bite." : "The second Pane Bite will remove Elian's Guard and chip the neighbor."}</p></div>
|
||
<div class="playback-tools">${iconButton(state.battlePaused ? "▶" : "Ⅱ", state.battlePaused ? "Resume" : "Pause", "data-battle-pause")}
|
||
<div class="speed-control">${[1, 2, 4].map((speed) => `<button class="${state.battleSpeed === speed ? "is-active" : ""}" data-battle-speed="${speed}">${speed}x</button>`).join("")}</div>${iconButton("i", "Inspect event", "data-open-screen=\"BAT-003\"")}</div>
|
||
${battleLine()}
|
||
<div class="battle-event-log"><span>00:07.2 · Gust Finch targets Nia</span><b>00:08.0 · Elian intercepts Pane Bite</b><span>00:08.8 · Sable gains Remedy</span></div>
|
||
${inspect ? `<aside class="inspect-sheet"><span class="stage-kicker">PINNED CAUSE</span><h3>Elian intercepts Pane Bite</h3><dl><div><dt>Source</dt><dd>Hearthguard · Guard 1</dd></div><div><dt>Target</dt><dd>Sable · position 2</dd></div><div><dt>Order effect</dt><dd>Neighbor protection</dd></div><div><dt>Next cause</dt><dd>Guard reaches zero</dd></div></dl><button class="stage-primary" data-flow-next>Pin and continue</button></aside>` : ""}
|
||
<div class="caption-line">[Front recruit: Guard consumed] [Enemy forecast: rear projectile]</div>
|
||
${opening ? actionDock("Start observation", "Inspect objective", "data-flow-next") : ""}
|
||
</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderOutcome(screen, variant) {
|
||
if (screen.id === "OUT-001") {
|
||
const body = `<div class="result-composition"><div class="result-title"><span class="outcome-seal">VICTORY</span><div><span class="stage-kicker">FIRST MEANINGFUL DIVERGENCE</span><h2>Guard broke before the second rear shot</h2><p>Expected protection ended at 00:18.4; actual pressure reached Nia at 00:19.1.</p></div></div><div class="causal-timeline"><div class="expected-track"><b>EXPECTED</b><span>00:17 Guard 1</span><span>00:19 Rear shot blocked</span><span>00:22 Stable line</span></div><div class="actual-track"><b>ACTUAL</b><span>00:17 Pane chip</span><span class="is-divergence">00:18 Guard 0</span><span>00:19 Nia hit</span></div><svg aria-hidden="true" viewBox="0 0 100 20" preserveAspectRatio="none"><path d="M34 3 C42 3 42 17 51 17" /></svg></div><div class="result-effects"><span><b>Cause</b>Second uninterrupted Pane Bite</span><span><b>Order</b>Sable remained adjacent</span><span><b>Adapt</b>Move or interrupt before bite 2</span></div>${actionDock("Continue to Reward", "Full event log", "data-flow-next")}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
if (screen.id === "OUT-002") {
|
||
const body = `<div class="loss-composition"><div class="fallen-figure"><span>FS</span><i>FALLEN</i></div><div class="loss-record"><span class="stage-kicker">COMMITTED LOSS</span><h2>Nia Orrow</h2><p>Farstring · Patient Start · Level 3 · survived 4 battles</p><div class="loss-row"><span>Equipment</span><b>Returned to inventory</b></div><div class="loss-row"><span>Party gap</span><b>Rear pressure lost</b></div><div class="loss-row"><span>Replacement Credit</span><b>+1 tier</b></div><small>Death is permanent for this run. No relationship or personal-arc system is implied.</small></div>${actionDock("Acknowledge loss", "Inspect receipt", "data-flow-next")}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
if (screen.id === "OUT-003") {
|
||
const rewards = [["EQ-014", "Quiet Rearplate", "Converts first area hit to direct"], ["AR-011", "Narrow Vanguard", "One fewer deploy; front starts Guarded"], ["PF-B05", "Farstring insight", "Opens a profession trial"]];
|
||
const body = `<div class="reward-composition"><div class="reward-heading"><span class="stage-kicker">FIXED REWARD DRAFT</span><h2>Choose the next decision you want to change</h2></div><div class="reward-options">${rewards.map((reward, index) => `<button class="reward-option ${index === 0 ? "is-selected" : ""}"><span class="reward-object">${["▰", "◇", "◎"][index]}</span><small>${reward[0]}</small><strong>${reward[1]}</strong><p>${reward[2]}</p><em>${index === 0 ? "Selected" : "Compare"}</em></button>`).join("")}</div>${actionDock("Claim Quiet Rearplate", "Compare build", "data-flow-next")}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
if (screen.id === "OUT-004") {
|
||
const body = `<div class="recovery-composition"><div class="recovery-heading"><span class="stage-kicker">RECOVERY</span><h2>Company remains viable</h2><p>Replacement Credit reduces only a later hire. Lost level, Trait, profession and history stay lost.</p></div><div class="recovery-paths"><button class="recovery-path is-selected"><span>01</span><strong>Protected base hire</strong><small>46 → 28 Coin · consumes 1 Credit</small><p>Return with a new base profession at Market.</p></button><button class="recovery-path"><span>02</span><strong>Continue short-handed</strong><small>0 Coin · Company 3 / capacity 4</small><p>Keep resources and accept reduced coverage.</p></button><button class="recovery-path"><span>03</span><strong>Abandon run</strong><small>Run-close transaction</small><p>Retain history and committed progression only.</p></button></div>${actionDock("Commit protected hire", "Review consequences", "data-flow-next")}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
const rows = stageDetails[screen.id] || ["Outcome receipt verified", "No unresolved casualty", "Safe destination named"];
|
||
const body = `<div class="outcome-sheet"><span class="stage-kicker">${screen.id}</span><h2>${escapeHtml(screenName(screen))}</h2>${rows.map((row) => `<div class="sheet-row">${escapeHtml(row)}</div>`).join("")}<p>${truncate(screen.purpose, 180)}</p>${actionDock(screen.id === "OUT-006" ? "Hold to abandon run" : "Finish result", "Cancel")}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderRoster(screen, variant) {
|
||
if (screen.id === "REC-003") {
|
||
const body = `<div class="dismissal-review"><div class="review-figure">${recruitMarker(generatedOffers[2], true)}</div><div><span class="stage-kicker">DESTRUCTIVE REVIEW</span><h2>Dismiss Tomas Vale?</h2><div class="sheet-row">Equipment returns to run inventory</div><div class="sheet-row">No Replacement Credit</div><div class="sheet-row">Living Company 4 → 3</div><p>Eligibility is revalidated at commit. The last living recruit can never be dismissed.</p></div>${actionDock("Dismiss Recruit", "Cancel")}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
const roster = generatedOffers.concat([{ id: "RC-017", name: "Miro Sen", profession: "Coilsmith", trait: "Deep Pocket", mark: "CS" }]);
|
||
const body = `<div class="roster-composition"><div class="roster-list"><span class="stage-kicker">LIVING COMPANY · 5</span>${roster.map((offer, index) => `<button class="roster-row ${index === 1 ? "is-focused" : ""}">${recruitMarker(offer, index < 4)}<span>${index < 4 ? `Position ${3 - index}` : "Reserve"}</span></button>`).join("")}</div><aside class="recruit-detail"><span class="large-figure">${screen.id === "REC-002" ? "FS" : "KH"}</span><h2>${screen.id === "REC-002" ? "Nia Orrow" : "Sable Kest"}</h2><p>${screen.id === "REC-002" ? "Farstring · Patient Start" : "Kindhand · Shared Cup"}</p><div class="detail-grid"><span>Readiness<b>Ready</b></span><span>Position<b>3 · Rear</b></span><span>Equipment<b>Quiet Rearplate</b></span><span>History<b>4 survived</b></span></div><button class="stage-secondary">Open Equipment</button><button class="stage-primary">Open Party Line</button></aside></div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderGuild(screen, variant) {
|
||
const body = screen.id === "HUB-001" ? `<div class="guild-composition"><div class="guild-pressure"><span class="stage-kicker">CURRENT PRESSURE</span><h2>Rear projectile at the next crossing</h2><p>Gust Finch enters behind a visible Pane screen.</p></div><div class="guild-destinations"><button>Market <b>3 / 4 / 3</b></button><button>Roster <b>5 living</b></button><button>Equipment <b>1 new</b></button><button>Party Line <b>Ready</b></button><button class="is-primary">Risk Board <b>3 offers</b></button></div><div class="muster-strip">${partyActors()}</div><div class="receipt-strip"><b>Latest receipt</b><span>Quiet Rearplate claimed · no unresolved transaction</span></div></div>` : `<div class="forecast-composition"><div class="forecast-horizon"><span>NOW</span><i></i><span>ROUND 4</span><i></i><span>BOSS MUSTER</span></div><div class="forecast-sheets"><article><span>KNOWN</span><h3>Gust Finch</h3><p>Targets rearmost ally, then steps forward.</p><b>Counter: screen or pull</b></article><article><span>KNOWN</span><h3>Pane Nibbler</h3><p>Second uninterrupted bite chips neighbor Guard.</p><b>Counter: reorder or interrupt</b></article><article class="uncertain"><span>PARTIAL</span><h3>Third identity</h3><p>Family known: forced movement.</p><b>Confidence 68%</b></article></div></div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderSystem(screen, variant) {
|
||
if (screen.id === "SYS-005") return renderOpening(screen, variant === "primary" ? "offers2" : variant);
|
||
if (screen.id === "SYS-003") {
|
||
const body = `<div class="title-composition"><div class="title-brand"><span>AETHERBOUND</span><strong>GUILD</strong><small>Build the company. Order the line. Read the result.</small></div><div class="run-summary"><b>Safe resume</b><strong>Windglass Verge · Market</strong><span>Company 5 · Round 3 · save healthy</span><button class="stage-primary">Continue to Resume Review</button><button class="stage-secondary">New Run</button></div><div class="title-tools">${iconButton("⚙", "Settings")}${iconButton("Aa", "Accessibility")}</div></div>`;
|
||
return frame(screen, body, { variant, resources: false, context: "Aetherbound Guild" });
|
||
}
|
||
if (screen.id === "SYS-001") {
|
||
const body = `<div class="boot-composition"><div class="boot-mark"><i></i><span>AG</span></div><h2>Aetherbound Guild</h2><div class="verification-track"><i style="width:76%"></i></div><strong>Verifying local content · 76%</strong><small>Rules · Content · Settings · Save lineage</small></div>`;
|
||
return frame(screen, body, { variant, art: false, header: false });
|
||
}
|
||
if (screen.id === "SYS-002") return renderSettings(screen, variant, "FIRST-LAUNCH SETUP");
|
||
const details = stageDetails[screen.id] || [screen.purpose, screen.action];
|
||
const body = `<div class="system-workspace"><span class="stage-kicker">${screen.id}</span><h2>${escapeHtml(screenName(screen))}</h2><div class="system-list">${details.map((detail, index) => `<button class="system-row ${index === 0 ? "is-focused" : ""}"><span>0${index + 1}</span><strong>${escapeHtml(detail)}</strong><em>${index === 0 ? "Available" : "Inspect"}</em></button>`).join("")}</div><aside><b>Safe action</b><p>${truncate(screen.action, 160)}</p><button class="stage-primary">${screen.id === "SYS-007" ? "Load selected lineage" : "Open selected state"}</button></aside></div>`;
|
||
return frame(screen, body, { variant, resources: false });
|
||
}
|
||
|
||
function renderArtifact(screen, variant) {
|
||
const entries = state.data.artifacts.slice(0, 3);
|
||
const body = `<div class="artifact-composition"><div class="artifact-heading"><span class="stage-kicker">RUN-WIDE RULES · NOT EQUIPMENT</span><h2>Active Artifact Ledger</h2></div><div class="artifact-ledger">${entries.map((entry, index) => `<button class="artifact-row ${index === 0 ? "is-focused" : ""}"><span class="artifact-emblem">${["◇", "⌑", "⬡"][index]}</span><div><small>${entry.id}</small><strong>${entry.name}</strong><p>${truncate(entry.rule, 90)}</p></div><em>${index < 2 ? "Active" : "Conflict"}</em></button>`).join("")}</div><aside class="artifact-inspect"><h3>${entries[0].name}</h3><p>${entries[0].rule}</p><b>Conflict</b><span>${entries[0].tradeoff}</span><small>Lifecycle: current run only</small></aside></div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderProgress(screen, variant) {
|
||
if (screen.id === "PRO-003") {
|
||
const body = `<div class="records-composition"><div class="records-categories"><strong>COMPENDIUM</strong><button class="is-active">Professions <b>18/42</b></button><button>Traits <b>24/36</b></button><button>Equipment <b>73/320</b></button><button>Artifacts <b>11/60</b></button><button>Enemies <b>31/100</b></button><button>Bosses <b>2/16</b></button><button>Regions <b>2/8</b></button></div><div class="record-grid">${state.data.professions.slice(0, 6).map((entry, index) => `<button class="record-tile ${index === 0 ? "is-focused" : ""}"><span>${entry.name.slice(0, 2).toUpperCase()}</span><small>${entry.id}</small><strong>${entry.name}</strong><em>${index < 3 ? "Known" : "Undiscovered"}</em></button>`).join("")}</div><aside class="record-inspect"><span class="large-figure">HG</span><h2>Hearthguard</h2><p>Front hold · Guard intercept · reach 1</p><b>Counterplay</b><span>Rear pressure and Guard stripping</span></aside></div>`;
|
||
return frame(screen, body, { variant, art: false });
|
||
}
|
||
const rows = stageDetails[screen.id] || ["Decision grammar unlocked", "Prerequisite verified", "Receipt available"];
|
||
const isPostgame = screen.id.startsWith("PST");
|
||
const body = `<div class="progress-composition"><div class="progress-heading"><span class="stage-kicker">${isPostgame ? "POSTGAME" : "PROGRESSION"}</span><h2>${escapeHtml(screenName(screen))}</h2><p>${truncate(screen.purpose, 170)}</p></div><div class="progress-path"><span class="path-node is-complete">Base</span><i></i><span class="path-node is-current">Choice</span><i></i><span class="path-node">Mastery</span></div><div class="progress-details">${rows.map((row) => `<div class="sheet-row">${escapeHtml(row)}</div>`).join("")}</div>${actionDock(isPostgame ? "Review active rules" : "Inspect choice", "Ordinary New Run")}</div>`;
|
||
return frame(screen, body, { variant, art: false });
|
||
}
|
||
|
||
function renderSettings(screen, variant, kicker = "SETTINGS") {
|
||
const rows = settingRows[screen.id] || settingRows["SET-002"];
|
||
const categoryNames = ["Display", "Motion", "Audio", "Controls", "Language", "Information", "Save / Data", "Reset"];
|
||
const settingKeys = {
|
||
"SET-002-2": "contrast",
|
||
"SET-003-0": "reducedMotion",
|
||
"SET-003-3": "lowPower",
|
||
"SET-004-3": "captions",
|
||
};
|
||
const controls = rows.map(([label, value], index) => {
|
||
if (screen.id === "SET-002" && index === 0) {
|
||
return `<div class="segmented-control">${["100", "115", "130"].map((scale) => `<button class="${state.settings.textScale === scale ? "is-active" : ""}" data-text-scale="${scale}">${scale}</button>`).join("")}</div>`;
|
||
}
|
||
if (screen.id === "SET-006" && index === 0) {
|
||
return `<div class="segmented-control"><button class="${state.locale === "en" ? "is-active" : ""}" data-locale="en">EN</button><button class="${state.locale === "zh_CN" ? "is-active" : ""}" data-locale="zh_CN">简中</button></div>`;
|
||
}
|
||
const key = settingKeys[`${screen.id}-${index}`];
|
||
if (key) {
|
||
const enabled = Boolean(state.settings[key]);
|
||
return `<button class="switch ${enabled ? "is-on" : ""}" type="button" data-setting-key="${key}" aria-pressed="${enabled}"><i></i></button>`;
|
||
}
|
||
if (index % 3 === 1) return `<button class="switch is-on" type="button" aria-pressed="true"><i></i></button>`;
|
||
if (index % 3 === 2) return `<button class="menu-control">${escapeHtml(value)}⌄</button>`;
|
||
return `<div class="segmented-control"><button>Low</button><button class="is-active">${escapeHtml(value)}</button><button>High</button></div>`;
|
||
});
|
||
const body = `<div class="settings-composition"><nav class="settings-rail"><strong>${kicker}</strong>${categoryNames.map((name, index) => `<button class="${screen.id === `SET-00${Math.min(9, index + 2)}` || (screen.id === "SET-001" && index === 0) ? "is-active" : ""}">${name}</button>`).join("")}</nav><div class="settings-workspace"><span class="stage-kicker">CALLER · ${variant === "pause" ? "PAUSED BATTLE" : "TITLE"}</span><h2>${escapeHtml(screenName(screen))}</h2>${rows.map(([label], index) => `<div class="setting-control"><div><strong>${escapeHtml(label)}</strong><small>${index === 0 ? "Live preview" : "Current value"}</small></div>${controls[index]}</div>`).join("")}<div class="settings-actions"><button class="stage-secondary">Discard</button><button class="stage-primary">Apply settings</button></div></div><aside class="settings-preview"><span>PREVIEW</span><div class="preview-target">TARGET <b>Pane Nibbler</b></div><p>Front recruit protected<br />[Enemy forecast: rear projectile]</p><strong>Readable at ${state.settings.textScale}%</strong></aside></div>`;
|
||
return frame(screen, body, { variant, art: false, header: false });
|
||
}
|
||
|
||
function renderGeneric(screen, variant) {
|
||
const details = stageDetails[screen.id] || [truncate(screen.purpose, 90), truncate(screen.action, 90), screen.input];
|
||
const body = `<div class="generic-composition"><div class="generic-subject"><span>${screen.id.split("-")[0]}</span><strong>${escapeHtml(screenName(screen))}</strong></div><div class="generic-decision"><span class="stage-kicker">CURRENT DECISION</span><h2>${escapeHtml(screenName(screen))}</h2><p>${truncate(screen.purpose, 180)}</p>${details.map((detail) => `<div class="sheet-row">${escapeHtml(detail)}</div>`).join("")}</div>${actionDock(truncate(screen.action.split(";")[0], 46), t("inspect"))}</div>`;
|
||
return frame(screen, body, { variant });
|
||
}
|
||
|
||
function renderPrimaryStage(screen, variant = "primary") {
|
||
const prefix = screen.id.split("-")[0];
|
||
if (prefix === "SYS") return renderSystem(screen, variant);
|
||
if (prefix === "HUB") return renderGuild(screen, variant);
|
||
if (prefix === "SHP") return renderMarket(screen, variant);
|
||
if (prefix === "REC") return renderRoster(screen, variant);
|
||
if (prefix === "EQP" || prefix === "PTY") return renderPreparation(screen, variant);
|
||
if (prefix === "ART") return renderArtifact(screen, variant);
|
||
if (prefix === "RSK") return renderRisk(screen, variant);
|
||
if (prefix === "BAT") return renderBattle(screen, variant);
|
||
if (prefix === "OUT") return renderOutcome(screen, variant);
|
||
if (prefix === "PRO" || prefix === "PST") return renderProgress(screen, variant);
|
||
if (prefix === "SET") return renderSettings(screen, variant);
|
||
return renderGeneric(screen, variant);
|
||
}
|
||
|
||
function renderStateVariant(screen, variant) {
|
||
if (variant === "primary" || (screen.id === "SYS-005" && !["loading", "error", "return"].includes(variant))) return renderPrimaryStage(screen, variant);
|
||
const source = variant === "loading" ? screen.emptyLoading : variant === "error" ? screen.errorConfirmation : screen.interruptionReturn;
|
||
const label = variant === "loading" ? "LOADING / EMPTY" : variant === "error" ? "ERROR / CONFIRMATION" : "INTERRUPTION / RETURN";
|
||
const base = renderPrimaryStage(screen, "primary");
|
||
const action = variant === "loading" ? t("retry") : variant === "error" ? "Review changed state" : t("returnSafe");
|
||
return base.replace(
|
||
"</section>",
|
||
`<div class="state-variant-overlay ${variant}"><div class="variant-sheet"><span class="stage-kicker">${label}</span><h2>${escapeHtml(screenName(screen))}</h2><p>${escapeHtml(truncate(source, 260))}</p><div><button class="stage-secondary">${t("back")}</button><button class="stage-primary">${escapeHtml(action)}</button></div></div></div></section>`,
|
||
);
|
||
}
|
||
|
||
function viewHeading(kicker, title, detail, badges = []) {
|
||
return `<header class="view-heading"><div><span class="eyebrow">${escapeHtml(kicker)}</span><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p></div><div class="heading-badges">${badges.map((badge) => `<span>${escapeHtml(badge)}</span>`).join("")}</div></header>`;
|
||
}
|
||
|
||
function renderFirstPlay() {
|
||
const step = flowSteps[state.flowIndex];
|
||
const screen = getScreen(step[0]);
|
||
return `<section class="view firstplay-view">
|
||
${viewHeading("OWNER REVIEW · FIRST 16 MINUTES", flowLabel(step), `${screen.id} · ${screenName(screen)}`, [`${state.flowIndex + 1} / ${flowSteps.length}`, "Real screen state"])}
|
||
<div class="stage-wrap">${renderPrimaryStage(screen, step[1])}</div>
|
||
<div class="firstplay-controls"><button class="page-command" type="button" data-flow-prev ${state.flowIndex === 0 ? "disabled" : ""}>‹ ${t("prev")}</button><div class="flow-progress" aria-label="First-play progress">${flowSteps.map((entry, index) => `<button class="${index === state.flowIndex ? "is-active" : ""} ${index < state.flowIndex ? "is-complete" : ""}" type="button" data-flow-index="${index}" title="${escapeHtml(flowLabel(entry))}"><span>${index + 1}</span></button>`).join("")}</div><button class="page-command is-primary" type="button" data-flow-next>${state.flowIndex === flowSteps.length - 1 ? "Return to first Market" : t("next")} ›</button></div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderScreens() {
|
||
const filtered = state.data.screens.filter((screen) => state.screenGroup === "ALL" || screen.id.startsWith(`${state.screenGroup}-`));
|
||
if (!filtered.some((screen) => screen.id === state.screenId)) state.screenId = filtered[0].id;
|
||
const screen = getScreen(state.screenId);
|
||
const index = filtered.findIndex((entry) => entry.id === screen.id);
|
||
const previous = filtered[(index - 1 + filtered.length) % filtered.length];
|
||
const next = filtered[(index + 1) % filtered.length];
|
||
const variants = screen.id === "SYS-005" ? [["contract", "A"], ["offers2", "B"], ["review", "C"], ["pending", "D"], ["receipt", "E"], ["loading", "Loading"], ["error", "Error"], ["return", "Return"]] : [["primary", "Primary"], ["loading", "Loading / empty"], ["error", "Error / confirm"], ["return", "Return"]];
|
||
return `<section class="view screens-view">
|
||
${viewHeading("50 CURRENT PAGE / STATE IDS", screenName(screen), `${screen.id} · ${screen.input}`, [`${index + 1} / ${filtered.length}`, "Page-specific composition"])}
|
||
<div class="screen-tools"><div class="filter-scroll">${Object.entries(screenGroups).map(([key, label]) => `<button class="${state.screenGroup === key ? "is-active" : ""}" data-screen-group="${key}">${label}</button>`).join("")}</div><div class="variant-control">${variants.map(([key, label]) => `<button class="${state.screenVariant === key ? "is-active" : ""}" data-screen-variant="${key}">${label}</button>`).join("")}</div></div>
|
||
<div class="screen-browser"><aside class="screen-index">${filtered.map((entry) => `<button class="${entry.id === screen.id ? "is-active" : ""}" type="button" data-screen-id="${entry.id}"><span>${entry.id}</span><strong>${escapeHtml(screenName(entry))}</strong><em>COMPOSED</em></button>`).join("")}</aside><div class="screen-inspection"><div class="stage-wrap">${renderStateVariant(screen, state.screenVariant)}</div><div class="screen-pager"><button data-screen-id="${previous.id}">‹ ${t("prev")} · ${previous.id}</button><button data-screen-id="${next.id}">${next.id} · ${t("next")} ›</button></div><div class="authority-summary"><div><span>Purpose</span><p>${escapeHtml(screen.purpose)}</p></div><div><span>Primary action</span><p>${escapeHtml(screen.action)}</p></div><div><span>Safe state</span><p>${escapeHtml(screen.interruptionReturn)}</p></div></div></div></div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderRegions() {
|
||
const region = state.data.regions[state.regionIndex];
|
||
const enemies = state.data.enemies.filter((entry) => entry.id.startsWith(`EN-R${state.regionIndex + 1}-`));
|
||
const bosses = state.data.bosses.filter((entry) => entry.id.startsWith(`BO-R${state.regionIndex + 1}-`));
|
||
return `<section class="view regions-view">
|
||
${viewHeading("EIGHT CAMPAIGN REGIONS", region.name, region.framing, [`${region.id}`, `${enemies.length} enemies · ${bosses.length} bosses`])}
|
||
<div class="region-tabs">${state.data.regions.map((entry, index) => `<button class="${index === state.regionIndex ? "is-active" : ""}" data-region-index="${index}"><span>${entry.id}</span><strong>${entry.name}</strong></button>`).join("")}</div>
|
||
<div class="region-stage"><img src="${sceneImages[state.regionIndex]}" alt="${escapeHtml(region.name)} concept direction" /><span class="concept-label">${t("concept")}</span><div class="region-title"><span>${region.id}</span><h2>${region.name}</h2><p>${region.framing}</p></div><div class="region-route"><i></i><button>Contract A</button><i></i><button>Boss 01</button><i></i><button>Contract B</button><i></i><button>Boss 02</button></div></div>
|
||
<div class="region-intel"><article><span>Build pressure</span><p>${region.pressure}</p></article><article><span>Encounter grammar</span><p>${region.progression}</p></article><article><span>Current identities</span><div class="identity-strip">${enemies.slice(0, 5).map((enemy) => `<b>${enemy.id}<small>${enemy.name}</small></b>`).join("")}${bosses.map((boss) => `<b class="boss">${boss.id}<small>${boss.name}</small></b>`).join("")}</div></article></div>
|
||
</section>`;
|
||
}
|
||
|
||
function catalogConfig() {
|
||
const configs = {
|
||
professions: { label: "Professions", image: "assets/web/el_profession_kits.webp", filters: [["all", "All 42"], ["base", "12 base"], ["advanced", "24 advanced"], ["hidden", "6 hidden"]], meta: (entry) => `${entry.family} · ${entry.role}`, body: (entry) => `${entry.rules} ${entry.strategy}` },
|
||
traits: { label: "Traits", image: "assets/web/el_heroes.webp", filters: [["all", "All 36"]], meta: () => "one per recruit", body: (entry) => `Benefit: ${entry.benefit} Cost: ${entry.cost}` },
|
||
equipment: { label: "Equipment", image: "assets/web/el_items.webp", filters: [["all", "All 320"], ["B", "Behavior B"], ["T", "Tradeoff T"]], meta: (entry) => `${entry.class} · ${entry.fit}`, body: (entry) => `${entry.effect} Tradeoff: ${entry.tradeoff}` },
|
||
artifacts: { label: "Artifacts", image: "assets/web/el_items.webp", filters: [["all", "All 60"]], meta: () => "run-wide rule", body: (entry) => `${entry.rule} ${entry.tradeoff}` },
|
||
enemies: { label: "Enemies", image: "assets/web/el_enemies.webp", filters: [["all", "All 100"], ["ordinary", "Ordinary"], ["elite", "Elite"]], meta: (entry) => entry.class, body: (entry) => `${entry.rule} Responses: ${entry.responses}` },
|
||
bosses: { label: "Bosses", image: "assets/web/el_enemies.webp", filters: [["all", "All 16"]], meta: (entry) => entry.class, body: (entry) => `${entry.rule} Responses: ${entry.responses}` },
|
||
};
|
||
return configs[state.catalogType];
|
||
}
|
||
|
||
function renderCatalogs() {
|
||
const config = catalogConfig();
|
||
const entries = state.data[state.catalogType];
|
||
const normalized = state.catalogQuery.trim().toLocaleLowerCase();
|
||
const filtered = entries.filter((entry) => {
|
||
const filterMatch = state.catalogFilter === "all" || entry.family === state.catalogFilter || entry.class === state.catalogFilter;
|
||
const queryMatch = !normalized || Object.values(entry).join(" ").toLocaleLowerCase().includes(normalized);
|
||
return filterMatch && queryMatch;
|
||
});
|
||
return `<section class="view catalog-view">
|
||
${viewHeading("CURRENT AUTHORED CATALOGS", config.label, "Search stable IDs, names, mechanics, tradeoffs and counterplay.", [`${filtered.length} / ${entries.length}`, "No filler rows"])}
|
||
<div class="catalog-tabs">${["professions", "traits", "equipment", "artifacts", "enemies", "bosses"].map((key) => `<button class="${state.catalogType === key ? "is-active" : ""}" data-catalog-type="${key}">${key}<b>${state.data.counts[key]}</b></button>`).join("")}</div>
|
||
<div class="catalog-toolbar"><div class="filter-scroll">${config.filters.map(([key, label]) => `<button class="${state.catalogFilter === key ? "is-active" : ""}" data-catalog-filter="${key}">${label}</button>`).join("")}</div><label class="search-control"><span>⌕</span><input type="search" data-catalog-search value="${escapeHtml(state.catalogQuery)}" placeholder="Search ID, name or mechanic" aria-label="Search catalog" /></label></div>
|
||
<div class="catalog-layout"><aside class="catalog-art"><img src="${config.image}" alt="Concept element direction" /><span class="concept-label">${t("concept")}</span><div><strong>${config.label}</strong><p>Concept board only. Stable IDs and mechanics come from the current authority.</p></div></aside><div class="catalog-list">${filtered.length ? filtered.slice(0, 100).map((entry) => `<article class="catalog-entry"><div><small>${entry.id}</small><strong>${entry.name}</strong><em>${escapeHtml(config.meta(entry))}</em></div><p>${escapeHtml(truncate(config.body(entry), 240))}</p><button class="stage-icon" aria-label="Inspect ${escapeHtml(entry.name)}" title="Inspect">i</button></article>`).join("") : `<div class="empty-state"><strong>No matching identities</strong><button data-clear-search>Clear search</button></div>`}${filtered.length > 100 ? `<div class="catalog-more">100 shown · ${filtered.length - 100} more remain searchable</div>` : ""}</div></div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderReview() {
|
||
const groups = [
|
||
["01", "Opening company", "SYS-005 A-E", "Four stable offers, exactly-two gating, revision, atomic receipt and first-Market handoff.", "firstplay", "0"],
|
||
["02", "Market hire", "SHP-001 / 002", "Two separate affordable base hires move Company 2 → 3 → 4 while capacity remains 4.", "firstplay", "7"],
|
||
["03", "Formation and equip", "EQP-001 / 002 · PTY-001 / 002", "Behavior delta and one rear-left to front-right line with exposed position 0.", "screens", "EQP-002"],
|
||
["04", "Horizontal battle", "BAT-001..006", "Automatic execution, one ordered line, urgent cause, pause, speed, inspect and retreat review.", "screens", "BAT-002"],
|
||
["05", "Result and recovery", "OUT-001..006", "Earliest divergence precedes totals; loss, reward, recovery and abandonment remain distinct.", "screens", "OUT-001"],
|
||
["06", "Catalog and settings", "PRO-003 · SET-001..009", "Searchable current counts plus EN/zh_CN, 130%, contrast, captions, reduced motion and input states.", "catalogs", "professions"],
|
||
];
|
||
return `<section class="view review-view">
|
||
${viewHeading("OWNER DECISION SURFACE", "Six critical review groups", "Each group opens the real composition used in the 50-state atlas.", ["Design review only", "Human gate open"])}
|
||
<div class="review-groups">${groups.map((group) => `<article><span>${group[0]}</span><div><small>${group[2]}</small><h2>${group[1]}</h2><p>${group[3]}</p></div><button class="page-command" data-review-view="${group[4]}" data-review-target="${group[5]}">Open group ›</button></article>`).join("")}</div>
|
||
<div class="review-boundary"><div><span>Machine-verifiable</span><strong>50 unique states · current counts · responsive layout · interactions · self-contained bundle</strong></div><div><span>Owner remains responsible</span><strong>Comprehension · visual acceptance · fun · listening · device ergonomics · development approval</strong></div></div>
|
||
</section>`;
|
||
}
|
||
|
||
function applyLocale() {
|
||
document.documentElement.lang = state.locale === "zh_CN" ? "zh-CN" : "en";
|
||
$$('[data-copy]').forEach((element) => { element.textContent = t(element.dataset.copy); });
|
||
$$('[data-locale]').forEach((button) => button.classList.toggle("is-active", button.dataset.locale === state.locale));
|
||
$("#view-title").textContent = t(state.view);
|
||
}
|
||
|
||
function applySettings() {
|
||
document.body.classList.toggle("high-contrast", state.settings.contrast);
|
||
document.body.classList.toggle("reduced-motion", state.settings.reducedMotion);
|
||
document.body.classList.toggle("low-power", state.settings.lowPower);
|
||
document.body.dataset.textScale = state.settings.textScale;
|
||
}
|
||
|
||
function render() {
|
||
if (!state.data) return;
|
||
const renderers = { firstplay: renderFirstPlay, screens: renderScreens, regions: renderRegions, catalogs: renderCatalogs, review: renderReview };
|
||
$("#content").innerHTML = renderers[state.view]();
|
||
$$('.nav-item').forEach((button) => button.classList.toggle("is-active", button.dataset.view === state.view));
|
||
applyLocale();
|
||
applySettings();
|
||
}
|
||
|
||
function setView(view) {
|
||
state.view = view;
|
||
history.replaceState(null, "", `#${view}`);
|
||
render();
|
||
closeMobileNav();
|
||
$("#content").focus({ preventScroll: true });
|
||
}
|
||
|
||
function closeMobileNav() {
|
||
$("#sidebar").classList.remove("is-open");
|
||
$("#nav-scrim").hidden = true;
|
||
}
|
||
|
||
function openMobileNav() {
|
||
$("#sidebar").classList.add("is-open");
|
||
$("#nav-scrim").hidden = false;
|
||
}
|
||
|
||
function advanceFlow(delta) {
|
||
const next = state.flowIndex + delta;
|
||
state.flowIndex = next < 0 ? 0 : next >= flowSteps.length ? 7 : next;
|
||
if (state.view !== "firstplay") state.view = "firstplay";
|
||
if ($("#stage-dialog").open) $("#stage-dialog").close();
|
||
render();
|
||
}
|
||
|
||
function handleClick(event) {
|
||
const button = event.target.closest("button");
|
||
if (!button) return;
|
||
if (button.dataset.view) return setView(button.dataset.view);
|
||
if (button.dataset.locale) {
|
||
state.locale = button.dataset.locale;
|
||
return render();
|
||
}
|
||
if (button.hasAttribute("data-flow-next")) return advanceFlow(1);
|
||
if (button.hasAttribute("data-flow-prev")) return advanceFlow(-1);
|
||
if (button.dataset.flowIndex) {
|
||
state.flowIndex = Number(button.dataset.flowIndex);
|
||
return render();
|
||
}
|
||
if (button.dataset.screenGroup) {
|
||
state.screenGroup = button.dataset.screenGroup;
|
||
state.screenVariant = "primary";
|
||
return render();
|
||
}
|
||
if (button.dataset.screenId) {
|
||
state.screenId = button.dataset.screenId;
|
||
state.screenVariant = "primary";
|
||
if (state.view !== "screens") state.view = "screens";
|
||
return render();
|
||
}
|
||
if (button.dataset.screenVariant) {
|
||
state.screenVariant = button.dataset.screenVariant;
|
||
return render();
|
||
}
|
||
if (button.dataset.regionIndex) {
|
||
state.regionIndex = Number(button.dataset.regionIndex);
|
||
return render();
|
||
}
|
||
if (button.dataset.catalogType) {
|
||
state.catalogType = button.dataset.catalogType;
|
||
state.catalogFilter = "all";
|
||
state.catalogQuery = "";
|
||
return render();
|
||
}
|
||
if (button.dataset.catalogFilter) {
|
||
state.catalogFilter = button.dataset.catalogFilter;
|
||
return render();
|
||
}
|
||
if (button.dataset.offerId) {
|
||
const id = button.dataset.offerId;
|
||
const index = state.openingSelected.indexOf(id);
|
||
if (index >= 0) state.openingSelected.splice(index, 1);
|
||
else if (state.openingSelected.length < 2) state.openingSelected.push(id);
|
||
state.flowIndex = state.openingSelected.length === 0 ? 1 : state.openingSelected.length === 1 ? 2 : 3;
|
||
return render();
|
||
}
|
||
if (button.hasAttribute("data-party-swap")) {
|
||
state.partySwapped = !state.partySwapped;
|
||
return render();
|
||
}
|
||
if (button.hasAttribute("data-battle-pause")) {
|
||
state.battlePaused = !state.battlePaused;
|
||
return render();
|
||
}
|
||
if (button.dataset.battleSpeed) {
|
||
state.battleSpeed = Number(button.dataset.battleSpeed);
|
||
return render();
|
||
}
|
||
if (button.dataset.openScreen) {
|
||
state.screenId = button.dataset.openScreen;
|
||
state.screenVariant = "primary";
|
||
return setView("screens");
|
||
}
|
||
if (button.dataset.settingKey) {
|
||
const key = button.dataset.settingKey;
|
||
state.settings[key] = !state.settings[key];
|
||
return render();
|
||
}
|
||
if (button.dataset.textScale) {
|
||
state.settings.textScale = button.dataset.textScale;
|
||
return render();
|
||
}
|
||
if (button.dataset.reviewView) {
|
||
const target = button.dataset.reviewTarget;
|
||
if (button.dataset.reviewView === "firstplay") state.flowIndex = Number(target);
|
||
if (button.dataset.reviewView === "screens") state.screenId = target;
|
||
if (button.dataset.reviewView === "catalogs") state.catalogType = target;
|
||
return setView(button.dataset.reviewView);
|
||
}
|
||
if (button.hasAttribute("data-clear-search")) {
|
||
state.catalogQuery = "";
|
||
return render();
|
||
}
|
||
}
|
||
|
||
function handleInput(event) {
|
||
if (!event.target.matches("[data-catalog-search]")) return;
|
||
state.catalogQuery = event.target.value;
|
||
render();
|
||
const replacement = $("[data-catalog-search]");
|
||
replacement.focus();
|
||
replacement.setSelectionRange(replacement.value.length, replacement.value.length);
|
||
}
|
||
|
||
let viewerZoom = 1;
|
||
|
||
function openCurrentStage() {
|
||
const stage = $(".game-stage") || $(".region-stage");
|
||
if (!stage) return;
|
||
viewerZoom = 1;
|
||
$("#zoom-reset").textContent = "100%";
|
||
$("#dialog-stage").style.width = "100%";
|
||
$("#dialog-stage").replaceChildren(stage.cloneNode(true));
|
||
$("#viewer-label").textContent = stage.dataset.stageId || "Region composition";
|
||
$("#stage-dialog").showModal();
|
||
}
|
||
|
||
function setViewerZoom(delta) {
|
||
viewerZoom = Math.min(2, Math.max(0.75, Math.round((viewerZoom + delta) * 4) / 4));
|
||
$("#dialog-stage").style.width = `${viewerZoom * 100}%`;
|
||
$("#zoom-reset").textContent = `${Math.round(viewerZoom * 100)}%`;
|
||
}
|
||
|
||
async function init() {
|
||
try {
|
||
const embeddedData = $("#prototype-data");
|
||
if (embeddedData) state.data = JSON.parse(embeddedData.textContent);
|
||
else {
|
||
const response = await fetch("data/catalog.json");
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
state.data = await response.json();
|
||
}
|
||
const hashView = location.hash.replace("#", "");
|
||
if (["firstplay", "screens", "regions", "catalogs", "review"].includes(hashView)) state.view = hashView;
|
||
render();
|
||
} catch (error) {
|
||
$("#content").innerHTML = `<div class="empty-state"><h1>Prototype data unavailable</h1><p>${escapeHtml(error.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
document.addEventListener("click", handleClick);
|
||
document.addEventListener("input", handleInput);
|
||
$("#open-nav").addEventListener("click", openMobileNav);
|
||
$("#close-nav").addEventListener("click", closeMobileNav);
|
||
$("#nav-scrim").addEventListener("click", closeMobileNav);
|
||
$("#fullscreen-current").addEventListener("click", openCurrentStage);
|
||
$("#close-dialog").addEventListener("click", () => $("#stage-dialog").close());
|
||
$("#zoom-out").addEventListener("click", () => setViewerZoom(-0.25));
|
||
$("#zoom-in").addEventListener("click", () => setViewerZoom(0.25));
|
||
$("#zoom-reset").addEventListener("click", () => { viewerZoom = 1; setViewerZoom(0); });
|
||
$("#stage-dialog").addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.close(); });
|
||
window.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMobileNav(); });
|
||
|
||
init();
|