feat(p9.1): add representative battle visual slice

This commit is contained in:
2026-08-13 10:25:07 +08:00
parent 0823b35597
commit 71b7f71ae3
37 changed files with 1091 additions and 0 deletions
@@ -0,0 +1,54 @@
# ABG-P9.1-VISUAL-SLICE Candidate Evidence
> Legal terminal: `ABG-P9.1-VISUAL-SLICE candidate ready`
> Source baseline after dispatch recovery: `efffb7b715d981eeb499628fea77e05ff8b5695e`
> PMO activation ancestor observed before candidate commit: `3a02d51c48b206b040090111c3fa8d8743ac731e`
> Candidate closure: populated by the Git commit containing this file
## Produced
- One play-size `844x390` graybox with fixed tracks and first/second/third
focal priorities.
- One editable HTML/CSS/JS slice covering formation review, approach/contact,
damage, heal, ranged response and first causal diagnosis.
- Seven `844x390` previews: one graybox plus six source-bound frames.
- Four-Recruit identity inventory, accepted action/enemy/VFX sources, font
license, source paths and SHA-256 verification.
- Explicit environment, actor, connector, feedback, UI and caption layers.
## Verification
Command:
```text
npm ci && npm run capture && npm run verify
```
Result: `PASS`. `evidence/verification.json` records:
- viewport: `844x390`;
- frame count: `6`;
- copied asset count: `14`;
- viewport overflow: `0` errors;
- required pair identity copy: present in every frame;
- accepted source hash mismatch: `0`;
- paid calls: `0 / 4`;
- paid cost: `$0 / $5`.
Visual inspection covered every preview at original `844x390`. Actor order,
target lines, signed damage/heal values, captions and cause-first result copy
remain readable without incoherent overlap.
## Scope And Cleanup
- Changed paths are confined to `docs/runtime/p9_1/visual_slice/`.
- Runtime source, tests, accepted runtime assets and PMO files were not edited.
- No image, audio or video generation process was started.
- Local Playwright dependencies were removed after evidence capture.
- Owned processes: none.
## Residual Risk
This is a static visual/layer reference, not runtime integration. Owner visual
judgment and runtime comprehension, motion, sound, device, packaging and release
gates remain open exactly as required by the frozen contract.
+50
View File
@@ -0,0 +1,50 @@
# P9.1 Representative Visual Slice Graybox
> Viewport: `844x390`
> Fixture: `zh_CN`, 100%, standard motion/flashes, captions on
> Pair: `Farstring + Kindhand`
## Composition
The play-size authority is [`graybox.html`](graybox.html). Its tracks match
`UX_VISUAL_FLOW_SPEC.md` exactly:
| Track | Bounds | Purpose |
|---|---:|---|
| Top status rail | `12,10,820,54` | Context, progress and non-command tools |
| Main world/event | `18,72,544,242` | Party line, enemy, connectors and event proof |
| Context rail | `570,72,256,242` | Current evidence, exact delta and causal explanation |
| Bottom action dock | `18,322,808,56` | One next action; event ribbon replaces it in battle |
The battle variant expands the painted world behind the edge UI. The event
ribbon uses the dock footprint so it cannot cover actors or feedback numbers.
## Focal Priorities
1. **Current event and affected actor.** The party line, target mark and exact
number must resolve before any result summary.
2. **Cause and changed value.** The right rail or event ribbon names source,
action, target, before/after and result.
3. **Next legal action.** A single gold enamel control occupies a stable
`248x56` visual plate inside the `56`-high dock.
Formation review changes priority 1 to the committed automatic order. Result 1
changes priority 1 to the earliest causal divergence, while preserving the same
actor identities and left-to-right order.
## Readability Decisions
- Kindhand remains protected-left and Farstring exposed-right in every frame.
- The enemy-facing action gap stays clear of text and controls.
- Damage uses coral plus a split-triangle family; healing uses jade plus leaf
chevrons. Signed values and before/after text remain authoritative.
- No player combat controls are introduced. Pause, Inspect and Captions remain
symbol tools in the top rail.
- Functional text is engine-rendered and no generated asset contains text.
## Media Decision
Paid generation was not needed. Accepted P9.0 assets already provide the two
identities, action poses, battlefield, enemy and semantic VFX required by this
bounded proof. Reusing those accepted sources reduces identity drift and keeps
all four authorized calls in reserve; the final call count is still zero.
+81
View File
@@ -0,0 +1,81 @@
# P9.1 Formation-To-Diagnosis Visual Handoff
## Scope
This handoff covers one representative chain only:
```text
formation review -> approach/contact -> damage -> heal response
-> ranged finish -> first causal diagnosis
```
It is a design and layer reference, not integrated runtime source and not Owner
visual acceptance. Open `index.html?frame=<id>` at exactly `844x390`.
## Representative Truth
- All four accepted Recruit identity anchors are included in
`assets/actors/`; this representative pair renders only Farstring and
Kindhand and does not imply that either is a fixed protagonist.
- Starting pair: `Farstring + Kindhand` (`FS-KH`).
- Automatic order: `Farstring` is order `0`, exposed-right lead; `Kindhand` is
order `1`, protected-left second member.
- Battle 1 starts both at `100 HP`; exposed Farstring takes `-42` to `58`;
Kindhand restores `+12` to `70`.
- The final remote response is a representative ordered event showing the
enemy at `34/100`; it is not presented as a new combat formula.
- Result 1 names the system order, both profession actions, current HP, the
once-only `+20 Coin` receipt and the next adaptation comparison.
## Frames
| ID | Contract proof | First read |
|---|---|---|
| `formation-review` | `P91-E04` | Automatic order and both responsibilities |
| `approach-contact` | `P91-E05` contact | Actual target geometry before impact |
| `damage-feedback` | `P91-E05` damage | One `-42`, attached to Farstring, `100 -> 58` |
| `heal-response` | profession response | Kindhand-to-Farstring connector and one `+12` |
| `ranged-finish` | ranged response | Farstring-to-enemy direction and one `-30` |
| `first-diagnosis` | Result 1 | Earliest divergence, response and cause-first copy |
## Editable Layers
The HTML keeps layers semantic and independently replaceable:
1. `.layer-environment`: accepted hand-painted battlefield and scene wash.
2. `.layer-actors`: stable Recruit/enemy art and action poses.
3. `.layer-connectors`: party cord, target line, projectile and heal path.
4. `.layer-feedback`: signed number and semantic VFX.
5. `.layer-ui`: status, objective, context rail and controls.
6. `.layer-captions`: ordered event ribbon or cause-first result text.
Runtime implementation should bind those layers to immutable party snapshots
and ordered events. It must not reproduce branch logic from these static frames.
## Accessibility And Variants
- Every mechanic has text and geometry in addition to color.
- The current target remains visible before impact.
- Captions use `来源 -> 动作 -> 目标 -> 结果`.
- Touch controls are at least `48x48`; primary actions are `56` high.
- Reduced motion can use the same six stable poses and connectors with no
camera travel, parallax, bobbing or continuous motion.
- Reduced flashes can remove any transient emphasis without removing contours,
signed numbers or before/after values.
- Mute changes no geometry, timing or caption.
## Integration Notes
- Preserve accepted actor sources and their stable IDs; do not redraw names or
profession labels into raster art.
- Do not copy the static result values into UI logic. Bind resolved result
clauses from `P91_RESULT`.
- Do not expose manual order, front/rear toggles or combat commands.
- The result context rail is deliberately dense at 100% and is not a 130%
acceptance frame. Runtime 130% reflow follows the frozen spec.
## Residual Gates
- Owner visual judgment remains open.
- Runtime comprehension, motion feel, sound, devices, packaging and release
remain outside this visual-slice handoff.
+35
View File
@@ -0,0 +1,35 @@
# P9.1 Representative First-Battle Visual Slice
Open [`index.html`](index.html) with one of the frame query values in
[`frame-manifest.json`](frame-manifest.json), for example:
```text
index.html?frame=formation-review
index.html?frame=approach-contact
index.html?frame=damage-feedback
index.html?frame=heal-response
index.html?frame=ranged-finish
index.html?frame=first-diagnosis
```
The source is an editable HTML/CSS/JS layer reference at fixed `844x390`.
`GRAYBOX.md` records composition and focal priorities; `HANDOFF.md` records
bindings, layers and runtime cautions; the two JSON manifests record frame and
asset provenance. `generation-record.json` records `0` paid calls and `$0`.
## Evidence Commands
From this directory:
```bash
npm ci
npm run capture
npm run verify
```
`capture` writes the seven PNGs under `previews/`. `verify` checks every frame
at `844x390`, rejects viewport overflow or missing pair identity, confirms the
paid-call ceiling, and compares each copied asset byte-for-byte with its
accepted repository source. It writes `evidence/verification.json`.
Delete `node_modules/` after verification; it is not part of the handoff.
@@ -0,0 +1,20 @@
{
"revision": "p91-visual-slice-r1",
"policy": "Repository-accepted sources copied into the Goal-owned handoff for a self-contained editable reference; no third-party commercial assets and no new paid generation.",
"assets": [
{"path": "assets/backgrounds/first_expedition_field.png", "source": "runtime/assets/backgrounds/first_expedition_field.png", "use": "environment"},
{"path": "assets/actors/hearthguard.png", "source": "runtime/assets/actors/hearthguard.png", "use": "four-Recruit identity inventory; not rendered in this pair"},
{"path": "assets/actors/wayrunner.png", "source": "runtime/assets/actors/wayrunner.png", "use": "four-Recruit identity inventory; not rendered in this pair"},
{"path": "assets/actors/farstring.png", "source": "runtime/assets/actors/farstring.png", "use": "formation/result identity"},
{"path": "assets/actors/kindhand.png", "source": "runtime/assets/actors/kindhand.png", "use": "formation/result identity"},
{"path": "assets/actions/farstring/idle.png", "source": "runtime/assets/animations/farstring/idle/frame-0.png", "use": "battle idle"},
{"path": "assets/actions/farstring/draw-release.png", "source": "runtime/assets/animations/farstring/draw_release/frame-2.png", "use": "ranged response"},
{"path": "assets/actions/kindhand/idle.png", "source": "runtime/assets/animations/kindhand/idle/frame-0.png", "use": "battle idle"},
{"path": "assets/actions/kindhand/lantern-heal.png", "source": "runtime/assets/animations/kindhand/lantern_heal/frame-2.png", "use": "heal response"},
{"path": "assets/enemies/riftblade-idle.png", "source": "docs/prototype/generation/phase6_motion_proof_03/runs/enemy-riftblade/anchors/idle.png", "use": "accepted enemy identity"},
{"path": "assets/vfx/damage-heavy.png", "source": "runtime/assets/vfx/damage_heavy.png", "use": "damage event accent"},
{"path": "assets/vfx/heal-safe.png", "source": "runtime/assets/vfx/protect_gain.png", "use": "heal event accent"},
{"path": "assets/fonts/NotoSansSC.ttf", "source": "runtime/assets/fonts/NotoSansSC.ttf", "use": "engine-rendered Chinese UI"},
{"path": "assets/fonts/OFL.txt", "source": "runtime/assets/fonts/OFL.txt", "use": "font license"}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,92 @@
Copyright 2014, 2015 Adobe Systems Incorporated (http://www.adobe.com/).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,38 @@
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL(".", import.meta.url));
const frames = ["graybox", "formation-review", "approach-contact", "damage-feedback", "heal-response", "ranged-finish", "first-diagnosis"];
const mime = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".ttf": "font/ttf", ".json": "application/json" };
const server = createServer(async (request, response) => {
const rawPath = decodeURIComponent((request.url || "/").split("?")[0]);
const relativePath = rawPath === "/" ? "index.html" : rawPath.replace(/^\//, "");
try {
const body = await readFile(join(root, relativePath));
response.writeHead(200, { "content-type": mime[extname(relativePath)] || "application/octet-stream" });
response.end(body);
} catch {
response.writeHead(404);
response.end("Not found");
}
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 844, height: 390 }, deviceScaleFactor: 1 });
for (const id of frames) {
const url = id === "graybox"
? `http://127.0.0.1:${port}/graybox.html`
: `http://127.0.0.1:${port}/index.html?frame=${id}`;
await page.goto(url, { waitUntil: "networkidle" });
await page.screenshot({ path: join(root, "previews", `${id}.png`) });
}
await browser.close();
server.close();
@@ -0,0 +1,70 @@
{
"ok": true,
"viewport": [
844,
390
],
"frame_count": 6,
"asset_count": 14,
"paid_calls_used": 0,
"paid_cost_usd": 0,
"errors": [],
"hashes": {
"assets/backgrounds/first_expedition_field.png": {
"source": "runtime/assets/backgrounds/first_expedition_field.png",
"sha256": "d8c1d2fb7fb466ef1abf8f9809ed9db276359d4905d260771ebd70be39fc3512"
},
"assets/actors/hearthguard.png": {
"source": "runtime/assets/actors/hearthguard.png",
"sha256": "fac57d23584b713e39817ea36dc432743b63acd18c74dbb5dfabb1f819685a18"
},
"assets/actors/wayrunner.png": {
"source": "runtime/assets/actors/wayrunner.png",
"sha256": "b1c06349cd91c84d86912579aa1137f634e52abeb983373fdea2a6857fc3f239"
},
"assets/actors/farstring.png": {
"source": "runtime/assets/actors/farstring.png",
"sha256": "0f7eda3b8ac4431fb5a9401e02d37cd4addea4d08d94568331654b8d4c405a26"
},
"assets/actors/kindhand.png": {
"source": "runtime/assets/actors/kindhand.png",
"sha256": "899eb7eb81af5acc2b85d344a87f3d0d15f2299d585dbb8fcb1031f19cc242e9"
},
"assets/actions/farstring/idle.png": {
"source": "runtime/assets/animations/farstring/idle/frame-0.png",
"sha256": "5a4e91eebd04d3ac70e86c576ca76d7be958329a825bdec316b46343cddb512c"
},
"assets/actions/farstring/draw-release.png": {
"source": "runtime/assets/animations/farstring/draw_release/frame-2.png",
"sha256": "8c339527f41cef9f6c194ac517aa1071ca6ddec7b90546998b6b1cf56167bf3d"
},
"assets/actions/kindhand/idle.png": {
"source": "runtime/assets/animations/kindhand/idle/frame-0.png",
"sha256": "7c82a307e1cadc2879e45c61e514748477db576a8f7c6e30753fdcbb9459230f"
},
"assets/actions/kindhand/lantern-heal.png": {
"source": "runtime/assets/animations/kindhand/lantern_heal/frame-2.png",
"sha256": "f883bacc2b9a0445ec0848f66364a69b7a632212411af39e5ba26668a61393b1"
},
"assets/enemies/riftblade-idle.png": {
"source": "docs/prototype/generation/phase6_motion_proof_03/runs/enemy-riftblade/anchors/idle.png",
"sha256": "e1c706cda49d5a969d8b3b79cf0d249e68eda62317cd10482573b4e2b4694b9a"
},
"assets/vfx/damage-heavy.png": {
"source": "runtime/assets/vfx/damage_heavy.png",
"sha256": "6204571743ed7914929f4fef1ab835b0c521023e5e93e9db5e65f6aaa875478f"
},
"assets/vfx/heal-safe.png": {
"source": "runtime/assets/vfx/protect_gain.png",
"sha256": "4c3d9310eb3a420beb0e4919f27df131c7393a3f06e1af59c3e0c12a5472b8fd"
},
"assets/fonts/NotoSansSC.ttf": {
"source": "runtime/assets/fonts/NotoSansSC.ttf",
"sha256": "a0af66489baf1f306732ff16da58d86c342e0b3b4b3674079ee2a9b0aee23258"
},
"assets/fonts/OFL.txt": {
"source": "runtime/assets/fonts/OFL.txt",
"sha256": "e2b6ccef8ef613a8fad6571020ad77c9143273a752001b42293a089da46940d0"
}
}
}
@@ -0,0 +1,25 @@
{
"revision": "p91-visual-slice-r1",
"viewport": [844, 390],
"locale": "zh_CN",
"text_scale": "100%",
"motion": "standard",
"flashes": "standard",
"audio": "mute-safe; captions on",
"contrast": "standard",
"power": "standard",
"fixture": {
"starting_pair": ["Farstring", "Kindhand"],
"field_order": ["Farstring", "Kindhand"],
"lead_id": "Farstring",
"initial_second_id": "Kindhand"
},
"frames": [
{"id": "formation-review", "screen": "P91-FORMATION-REVIEW", "proof": "P91-E04", "focus": "commit_formation", "interactive": false},
{"id": "approach-contact", "screen": "P91-BATTLE-1", "proof": "P91-E05/contact", "focus": "captions", "interactive": false},
{"id": "damage-feedback", "screen": "P91-BATTLE-1", "proof": "P91-E05/damage", "focus": "captions", "interactive": false},
{"id": "heal-response", "screen": "P91-BATTLE-1", "proof": "profession-response/heal", "focus": "captions", "interactive": false},
{"id": "ranged-finish", "screen": "P91-BATTLE-1", "proof": "profession-response/ranged", "focus": "captions", "interactive": false},
{"id": "first-diagnosis", "screen": "P91-RESULT-1", "proof": "cause-first-result", "focus": "next_threat", "interactive": false}
]
}
@@ -0,0 +1,13 @@
{
"scope": "P9.1 representative first-battle/diagnosis visual slice",
"authorized_call_ceiling": 4,
"authorized_cost_ceiling_usd": 5.0,
"paid_calls_used": 0,
"returned_cost_usd": 0.0,
"task_ids": [],
"prompts": [],
"provider_expansion": false,
"decision": "No paid generation. Accepted P9.0 assets satisfy the bounded slice and preserve identity better than a new render.",
"accepted_outputs": [],
"rejected_outputs": []
}
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=844, initial-scale=1">
<title>P9.1 首战视觉切片灰盒</title>
<style>
* { box-sizing: border-box; }
html, body { width: 844px; height: 390px; margin: 0; overflow: hidden; }
body { font-family: sans-serif; color: #182127; background: #d7e0dd; }
.canvas { position: relative; width: 844px; height: 390px; }
.box { position: absolute; border: 2px solid #34424a; background: rgba(255,255,255,.52); }
.box b { display: block; padding: 4px 7px; color: white; background: #34424a; font-size: 12px; }
.top { left: 12px; top: 10px; width: 820px; height: 54px; }
.world { left: 18px; top: 72px; width: 544px; height: 242px; background: #a9c3b8; }
.rail { left: 570px; top: 72px; width: 256px; height: 242px; }
.dock { left: 18px; top: 322px; width: 808px; height: 56px; }
.party { position: absolute; left: 54px; top: 100px; width: 345px; height: 116px; border-bottom: 4px solid #34424a; }
.actor { position: absolute; bottom: 0; width: 84px; height: 104px; display: grid; place-items: center; color: white; background: #566970; border: 3px solid white; }
.second { left: 34px; }.lead { right: 30px; background: #c94e52; }
.enemy { position: absolute; right: 32px; top: 68px; width: 92px; height: 115px; display: grid; place-items: center; color: white; background: #855579; border: 3px solid #182127; }
.gap { position: absolute; left: 382px; top: 128px; width: 72px; border-top: 4px dashed #c94e52; }
.focal { position: absolute; padding: 3px 7px; color: white; font-weight: bold; font-size: 11px; border-radius: 2px; }
.first { left: 294px; top: 80px; background: #c94e52; }
.second-focal { left: 588px; top: 84px; background: #13818a; }
.third { right: 26px; bottom: 67px; background: #d6a72e; color: #182127; }
.rail ul { margin: 14px 12px; padding-left: 18px; font-size: 12px; line-height: 1.65; }
.dock .button { position: absolute; right: 8px; top: -2px; width: 248px; height: 56px; display: grid; place-items: center; color: #182127; background: #d6a72e; border: 2px solid #182127; font-weight: bold; }
</style>
</head>
<body>
<main class="canvas">
<section class="box top"><b>TOP STATUS RAIL · 820 x 54</b></section>
<section class="box world">
<b>MAIN WORLD / CAUSAL EVENT · 544 x 242</b>
<div class="party"><span class="actor second">1<br>第二位</span><span class="actor lead">0<br>先行位</span></div>
<div class="gap"></div><div class="enemy">敌人</div>
</section>
<aside class="box rail"><b>CONTEXT RAIL · 256 x 242</b><ul><li>当前证据</li><li>行动与目标</li><li>精确前后值</li><li>因果解释</li></ul></aside>
<footer class="box dock"><b>BOTTOM ACTION DOCK · 808 x 56</b><span class="button">PRIMARY ACTION · 248 x 56</span></footer>
<span class="focal first">1 · 当前事件 / 受影响角色</span>
<span class="focal second-focal">2 · 原因 / 精确变化</span>
<span class="focal third">3 · 下一步 ↓</span>
</main>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=844, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>P9.1 首战视觉切片</title>
<link rel="stylesheet" href="slice.css">
</head>
<body>
<main id="slice" class="slice" aria-live="polite"></main>
<script src="slice.js"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
{
"name": "aetherbound-p91-visual-slice",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aetherbound-p91-visual-slice",
"version": "1.0.0",
"devDependencies": {
"playwright": "1.62.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
@@ -0,0 +1,13 @@
{
"name": "aetherbound-p91-visual-slice",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"capture": "node capture.mjs",
"verify": "node verify.mjs"
},
"devDependencies": {
"playwright": "1.62.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

+176
View File
@@ -0,0 +1,176 @@
@font-face {
font-family: "Aetherbound UI";
src: url("assets/fonts/NotoSansSC.ttf") format("truetype");
font-display: swap;
}
:root {
--ink: #182127;
--ink-2: #34424a;
--mist: #f3f6f2;
--mist-2: #d7e0dd;
--teal: #13818a;
--gold: #d6a72e;
--jade: #27845e;
--coral: #c94e52;
--cobalt: #4169b1;
--plum: #855579;
--silver: #88969b;
--paper: rgba(246, 249, 244, 0.94);
}
* { box-sizing: border-box; }
html, body { width: 844px; height: 390px; margin: 0; overflow: hidden; }
body {
background: #10181d;
color: var(--ink);
font-family: "Aetherbound UI", sans-serif;
letter-spacing: 0;
}
button { font: inherit; color: inherit; }
.slice { position: relative; width: 844px; height: 390px; overflow: hidden; background: #c7d8cf; }
.status-rail {
position: absolute; z-index: 80; left: 12px; top: 10px; width: 820px; height: 54px;
display: flex; align-items: center; gap: 8px; padding: 2px 8px;
color: var(--mist); background: rgba(24, 33, 39, 0.94);
border: 1px solid rgba(243, 246, 242, 0.34); border-radius: 4px;
box-shadow: 0 4px 14px rgba(15, 24, 29, 0.25);
}
.tool {
width: 48px; height: 48px; flex: 0 0 48px; display: grid; place-items: center;
border: 1px solid rgba(243,246,242,.3); border-radius: 4px; background: #34424a; color: white;
}
.tool.active { border-color: #8fcfd1; box-shadow: inset 0 -3px 0 var(--teal); }
.icon { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.status-copy { min-width: 250px; display: flex; flex-direction: column; line-height: 1.05; }
.status-copy .eyebrow { color: #b9cac6; font-size: 9px; margin-bottom: 4px; }
.status-copy strong { color: white; font-size: 15px; font-weight: 700; }
.status-spacer { flex: 1; }
.resource, .battle-index { min-width: 70px; height: 42px; display: flex; flex-direction: column; justify-content: center; padding: 0 9px; border-left: 2px solid var(--gold); }
.resource span, .battle-index span { color: #bdcbc7; font-size: 9px; }
.resource b, .battle-index b { font-size: 12px; color: white; }
.field-art { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.formation-world { position: absolute; left: 18px; top: 72px; width: 544px; height: 242px; overflow: hidden; border: 1px solid rgba(24,33,39,.4); border-radius: 4px; background: #6f9b86; }
.formation-world .field-art { object-position: center 66%; filter: saturate(.72) brightness(.76); }
.formation-wash { position: absolute; inset: 0; background: linear-gradient(90deg, rgba(20,44,46,.42), rgba(39,56,50,.03) 58%, rgba(21,32,36,.32)); }
.formation-cord { position: absolute; left: 90px; right: 82px; bottom: 46px; height: 18px; border-bottom: 3px solid rgba(243,246,242,.75); }
.formation-cord:before, .formation-cord:after { content: ""; position: absolute; bottom: -7px; width: 10px; height: 10px; background: var(--gold); border: 2px solid var(--ink); transform: rotate(45deg); }
.formation-cord:before { left: 42px; }.formation-cord:after { right: 50px; }
.threat-gap { position: absolute; right: 12px; bottom: 56px; width: 90px; height: 35px; color: white; font-size: 9px; text-align: right; }
.threat-gap i { position: absolute; right: 0; bottom: 0; width: 75px; border-top: 2px dashed var(--coral); }
.threat-gap i:after { content:""; position:absolute; right:-1px; top:-5px; border-left:8px solid var(--coral); border-top:4px solid transparent; border-bottom:4px solid transparent; }
.actor { position: absolute; z-index: 24; left: var(--x); top: var(--y); width: 132px; height: 185px; }
.actor > img { position: absolute; left: 4px; top: 2px; width: 124px; height: 126px; object-fit: contain; object-position: 50% 100%; filter: drop-shadow(0 5px 3px rgba(15,23,28,.45)); }
.formation-world .actor { width: 142px; height: 175px; }
.formation-world .actor > img { width: 138px; height: 148px; left: 2px; top: 0; }
.actor-badge { position: absolute; z-index: 4; left: 0; top: 3px; display: flex; align-items: center; height: 30px; color: white; background: rgba(24,33,39,.93); border: 1px solid rgba(255,255,255,.46); }
.actor-badge b { width: 30px; height: 28px; display: grid; place-items: center; background: var(--gold); color: var(--ink); }
.actor-badge span { padding: 0 7px; font-size: 10px; }
.actor-meta { position: absolute; z-index: 4; left: 0; bottom: 0; width: 132px; min-height: 51px; padding: 5px 7px; color: white; background: rgba(24,33,39,.94); border-left: 3px solid var(--silver); border-radius: 2px; }
.actor.urgent .actor-meta { border-left-color: var(--coral); box-shadow: 0 0 0 1px rgba(201,78,82,.55); }
.actor-meta strong { display: block; font-size: 11px; line-height: 14px; }
.actor-meta small { display: block; margin-top: 2px; color: #d7e0dd; font-size: 8px; }
.hp { display: block; width: 100%; height: 6px; margin-top: 3px; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.33); }
.hp i { display: block; width: var(--hp); height: 100%; background: var(--jade); }
.formation-world .actor-meta { display: none; }
.formation-world .actor-badge { top: 112px; left: 14px; }
.formation-note { position: absolute; bottom: 7px; z-index: 32; width: 185px; min-height: 42px; padding: 5px 7px; background: rgba(243,246,242,.94); border-left: 3px solid var(--teal); box-shadow: 0 2px 8px rgba(10,20,24,.22); }
.formation-note b { display: block; font-size: 10px; }
.formation-note span { display: block; font-size: 9px; line-height: 13px; color: var(--ink-2); }
.note-second { left: 18px; }.note-lead { left: 260px; border-left-color: var(--coral); }
.context-rail { position: absolute; z-index: 60; left: 570px; top: 72px; width: 256px; height: 242px; padding: 13px 14px; overflow: hidden; background: var(--paper); border: 1px solid #b7c3bf; border-radius: 4px; box-shadow: 0 3px 12px rgba(20,31,36,.16); }
.rail-kicker { display: block; color: var(--teal); font-size: 9px; font-weight: 800; }
.context-rail h2 { margin: 3px 0 7px; font-size: 17px; line-height: 21px; }
.context-rail p { margin: 0 0 8px; font-size: 10px; line-height: 14px; color: var(--ink-2); }
.evidence-row { display: grid; grid-template-columns: 31px 1fr; gap: 7px; align-items: start; margin: 7px 0; padding-top: 6px; border-top: 1px solid #cbd4d1; }
.evidence-row span { font-size: 9px; line-height: 13px; }
.evidence-row b { display: block; font-size: 10px; }
.symbol { width: 27px; height: 27px; display: grid; place-items: center; font-style: normal; font-weight: 900; color: white; background: var(--coral); clip-path: polygon(50% 0,100% 20%,84% 100%,16% 100%,0 20%); }
.symbol.heal { background: var(--jade); clip-path: polygon(50% 0,70% 28%,100% 50%,70% 72%,50% 100%,30% 72%,0 50%,30% 28%); }
.system-note { padding-left: 8px; border-left: 2px solid var(--gold); font-weight: 700; }
.action-dock { position: absolute; z-index: 80; left: 18px; top: 322px; width: 808px; height: 56px; display: flex; align-items: center; gap: 10px; padding: 0 8px; color: var(--mist); background: rgba(24,33,39,.96); border-radius: 4px; }
.action-dock > span { flex: 1; text-align: center; color: #bfcac7; font-size: 9px; }
.action-dock button { height: 48px; border-radius: 4px; }
.secondary { min-width: 146px; padding: 0 14px; color: white; border: 1px solid #77888e; background: #34424a; }
.primary { min-width: 248px; height: 56px !important; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 16px; color: var(--ink); border: 2px solid #f0c854; border-bottom: 5px solid #936c12; background: var(--gold); font-weight: 800; }
.primary .icon { width: 18px; height: 18px; }
.battlefield { position: absolute; z-index: 1; inset: 0; overflow: hidden; background: #588771; }
.battlefield .field-art { object-position: 50% 68%; filter: saturate(.78) brightness(.73) contrast(1.04); }
.field-wash { position: absolute; inset: 64px 0 0; background: linear-gradient(180deg, rgba(9,32,40,.16), rgba(17,31,33,.05) 47%, rgba(12,25,29,.72)); }
.objective { position: absolute; z-index: 8; left: 18px; top: 72px; width: 274px; min-height: 44px; padding: 7px 10px; color: white; background: rgba(24,33,39,.86); border-left: 3px solid var(--gold); }
.objective span { display: block; color: #bdd1cb; font-size: 8px; }.objective strong { display: block; margin-top: 2px; font-size: 12px; }
.party-ground { position: absolute; z-index: 4; left: 92px; top: 275px; width: 368px; height: 18px; border-bottom: 2px solid rgba(243,246,242,.55); transform: skewX(-10deg); }
.party-ground i { position: absolute; bottom: -6px; width: 11px; height: 11px; background: var(--gold); border: 2px solid var(--ink); transform: rotate(45deg); }
.party-ground i:first-child { left: 92px; }.party-ground i:last-child { left: 257px; }
.enemy { position: absolute; z-index: 24; left: 558px; top: 126px; width: 170px; height: 175px; }
.enemy-sprite { position: absolute; left: 12px; top: 17px; width: 144px; height: 132px; object-fit: contain; filter: drop-shadow(0 6px 4px rgba(12,16,21,.5)); transform: scaleX(-1); }
.enemy-name { position: absolute; z-index: 3; top: 0; right: 0; min-width: 120px; padding: 4px 7px; text-align: right; color: white; background: rgba(24,33,39,.93); border-right: 3px solid var(--plum); }
.enemy-name strong { display:block; font-size: 11px; }.enemy-name span { display:block; font-size:8px; color:#d4c6d1; }
.enemy-hp { position: absolute; z-index: 4; left: 18px; bottom: 10px; width: 136px; height: 7px; }
.enemy small { position: absolute; z-index: 4; right: 17px; bottom: -2px; color: white; font-size: 8px; text-shadow: 0 1px 2px black; }
.enemy.urgent .enemy-name { border-right-color: var(--coral); }
.event-ribbon { position: absolute; z-index: 70; left: 18px; bottom: 12px; width: 808px; height: 62px; display: grid; grid-template-columns: 84px 244px 1fr; align-items: center; gap: 10px; padding: 8px 11px; color: white; background: rgba(24,33,39,.96); border: 1px solid rgba(243,246,242,.35); border-radius: 4px; box-shadow: 0 -3px 14px rgba(12,20,25,.25); }
.event-step { align-self: stretch; display: grid; place-items: center; color: #d3e1dd; font-size: 9px; border-right: 1px solid #617178; }
.event-ribbon > strong { font-size: 12px; line-height: 16px; }
.event-chain { height: 38px; display: flex; align-items: center; justify-content: space-between; gap: 5px; padding: 0 8px; color: #d6e0dd; background: #2a373e; border-left: 3px solid var(--teal); font-size: 9px; }
.event-chain i { color: #819295; font-style: normal; }.event-chain b { color: white; font-size: 10px; }
.target-line { position: absolute; z-index: 20; left: 392px; top: 195px; width: 184px; border-top: 3px dashed var(--coral); transform: rotate(-4deg); transform-origin: left; }
.target-line:before, .target-line:after { content:""; position:absolute; top:-7px; width:11px; height:11px; border:2px solid var(--coral); transform:rotate(45deg); background:rgba(201,78,82,.18); }
.target-line:before { left:-4px; }.target-line:after { right:-4px; }
.target-line span { position:absolute; left:44px; top:-24px; padding:2px 7px; color:white; background:rgba(90,31,35,.86); font-size:9px; transform:rotate(4deg); }
.threat-mark { position:absolute; z-index:38; left:372px; top:137px; width:42px; height:40px; }
.threat-mark i { position:absolute; bottom:0; width:0; height:0; border-left:18px solid transparent; border-right:18px solid transparent; border-bottom:31px solid var(--coral); }
.threat-mark i:first-child { left:-8px; transform:rotate(-16deg); }.threat-mark i:nth-child(2) { right:-8px; transform:rotate(16deg); }
.threat-mark span { position:absolute; z-index:2; left:15px; top:13px; color:white; font-weight:900; }
.impact-line { position:absolute; z-index:20; left:390px; top:207px; width:181px; border-top:5px solid var(--coral); transform:rotate(-2deg); box-shadow:0 0 0 2px rgba(255,255,255,.42); }
.impact-line:after { content:""; position:absolute; left:-10px; top:-10px; width:19px; height:19px; background:var(--coral); clip-path:polygon(50% 0,100% 100%,0 100%); transform:rotate(-90deg); }
.damage-burst { position:absolute; z-index:35; left:350px; top:155px; width:86px; height:86px; }.vfx { width:100%; height:100%; object-fit:contain; }
.number { position:absolute; z-index:45; min-width:76px; padding:4px 7px; color:white; text-align:center; border:2px solid white; box-shadow:0 3px 0 rgba(24,33,39,.8); }
.number b { display:block; font-size:21px; line-height:22px; }.number span { display:block; font-size:8px; }
.damage-number { left:392px; top:122px; background:var(--coral); clip-path:polygon(0 0,100% 0,88% 100%,12% 100%); }
.heal-path { position:absolute; z-index:20; left:232px; top:214px; width:137px; border-top:4px solid var(--jade); transform:rotate(-11deg); box-shadow:0 0 0 1px rgba(255,255,255,.45); }
.heal-path i { position:absolute; top:-8px; width:12px; height:12px; background:var(--jade); clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%); }
.heal-path i:nth-child(1) { left:12px; }.heal-path i:nth-child(2) { left:61px; }.heal-path i:nth-child(3) { right:-2px; }
.heal-burst { position:absolute; z-index:35; left:345px; top:155px; width:82px; height:82px; }
.heal-number { left:388px; top:122px; background:var(--jade); clip-path:polygon(50% 0,100% 25%,92% 100%,8% 100%,0 25%); }
.projectile-line { position:absolute; z-index:26; left:389px; top:195px; width:190px; height:4px; background:var(--gold); box-shadow:0 0 0 1px white, 12px 0 8px var(--gold); transform:rotate(-2deg); }
.projectile-line:after { content:""; position:absolute; right:-10px; top:-6px; border-left:12px solid var(--gold); border-top:7px solid transparent; border-bottom:7px solid transparent; }
.projectile-line span { position:absolute; left:70px; top:-5px; width:9px; height:9px; border-radius:50%; background:white; box-shadow:0 0 9px var(--gold); }
.enemy-number { left:590px; top:119px; background:var(--coral); clip-path:polygon(0 0,100% 0,88% 100%,12% 100%); }
.result-world { position:absolute; left:18px; top:72px; width:544px; height:242px; overflow:hidden; border:1px solid rgba(24,33,39,.4); border-radius:4px; background:#547d6d; }
.result-world .field-art { object-position:48% 70%; filter:saturate(.55) brightness(.62); }
.result-wash { position:absolute; inset:0; background:linear-gradient(90deg,rgba(17,33,37,.68),rgba(25,44,43,.26)); }
.result-party img { position:absolute; z-index:10; bottom:18px; width:104px; height:168px; object-fit:contain; object-position:bottom; filter:drop-shadow(0 5px 3px rgba(8,17,20,.45)); }
.result-kindhand { left:18px; }.result-farstring { left:102px; }
.result-order { position:absolute; z-index:12; bottom:9px; padding:3px 7px; color:white; background:rgba(24,33,39,.94); font-size:8px; border-top:2px solid var(--gold); }
.result-order.second { left:17px; }.result-order.lead { left:115px; }
.cause-path { position:absolute; z-index:20; left:204px; top:20px; width:326px; height:204px; display:flex; flex-direction:column; justify-content:space-between; }
.cause-node { position:relative; min-height:50px; padding:6px 9px 6px 13px; color:white; background:rgba(24,33,39,.92); border-left:4px solid var(--teal); box-shadow:0 2px 7px rgba(8,16,20,.28); }
.cause-node span,.cause-node small { display:block; font-size:8px; color:#c6d6d1; }.cause-node b { display:block; margin:1px 0; font-size:13px; }.cause-node.divergence { border:2px solid white; border-left:5px solid var(--coral); box-shadow:0 0 0 2px var(--ink),0 0 0 4px var(--cobalt); }.cause-node.response { border-left-color:var(--jade); }
.cause-path > i { align-self:center; width:2px; height:12px; background:var(--gold); position:relative; }
.cause-path > i:after { content:""; position:absolute; left:-3px; bottom:-1px; border-top:6px solid var(--gold); border-left:4px solid transparent; border-right:4px solid transparent; }
.result-rail { padding:10px 12px; }
.result-rail h2 { margin-bottom:5px; font-size:15px; line-height:18px; }
.result-copy { margin:0; }
.result-copy div { display:grid; grid-template-columns:76px 1fr; gap:6px; padding:3px 0; border-top:1px solid #ccd5d2; }
.result-copy dt { color:var(--teal); font-size:9px; line-height:12px; font-weight:800; }.result-copy dd { margin:0; font-size:9px; line-height:12px; }
.result-copy div:nth-child(3) { border-left:3px solid var(--coral); padding-left:5px; background:rgba(201,78,82,.07); }
.result-dock .receipt { color:#cce8dc; }.result-dock .receipt .icon { width:15px; height:15px; margin-right:4px; vertical-align:-3px; color:#65bf91; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
+230
View File
@@ -0,0 +1,230 @@
const FRAME_IDS = [
"formation-review",
"approach-contact",
"damage-feedback",
"heal-response",
"ranged-finish",
"first-diagnosis",
];
const ASSETS = {
field: "assets/backgrounds/first_expedition_field.png",
farstring: "assets/actors/farstring.png",
kindhand: "assets/actors/kindhand.png",
farstringIdle: "assets/actions/farstring/idle.png",
farstringShot: "assets/actions/farstring/draw-release.png",
kindhandIdle: "assets/actions/kindhand/idle.png",
kindhandHeal: "assets/actions/kindhand/lantern-heal.png",
enemy: "assets/enemies/riftblade-idle.png",
damage: "assets/vfx/damage-heavy.png",
heal: "assets/vfx/heal-safe.png",
};
const frame = new URLSearchParams(window.location.search).get("frame") || FRAME_IDS[0];
const selectedFrame = FRAME_IDS.includes(frame) ? frame : FRAME_IDS[0];
function img(src, className, alt) {
return `<img class="${className}" src="${src}" alt="${alt}">`;
}
function icon(name) {
const icons = {
home: '<path d="M4 11 12 4l8 7v8h-5v-5H9v5H4z"/>',
restart: '<path d="M5 8a8 8 0 1 1-1 7m1-7V3m0 5h5"/>',
pause: '<path d="M7 5h3v14H7zm7 0h3v14h-3z"/>',
inspect: '<circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/>',
captions: '<path d="M4 6h16v11H9l-4 3v-3H4z"/><path d="M8 10h3m2 0h3m-8 3h5"/>',
arrow: '<path d="m7 12 9-7v14z"/>',
check: '<path d="m5 12 4 4L19 6"/>',
};
return `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`;
}
function statusRail(context, battle = false) {
return `
<header class="status-rail layer-ui">
<button class="tool" aria-label="返回首页">${icon("home")}</button>
<div class="status-copy">
<span class="eyebrow">第一份委托 · 断裂风路</span>
<strong>${context}</strong>
</div>
<div class="status-spacer"></div>
${battle ? '<div class="battle-index"><span>战斗 1 / 5</span><b>自动战斗</b></div>' : '<div class="resource"><span>小队</span><b>2</b></div>'}
<button class="tool" aria-label="重新开始">${icon("restart")}</button>
${battle ? `<button class="tool" aria-label="暂停">${icon("pause")}</button><button class="tool" aria-label="查看事件">${icon("inspect")}</button><button class="tool active" aria-label="字幕开启">${icon("captions")}</button>` : ''}
</header>`;
}
function actor({who, action = "idle", x, y, order, label, hp = 100, urgent = ""}) {
const actorSrc = who === "farstring"
? (action === "shot" ? ASSETS.farstringShot : action === "full" ? ASSETS.farstring : ASSETS.farstringIdle)
: (action === "heal" ? ASSETS.kindhandHeal : action === "full" ? ASSETS.kindhand : ASSETS.kindhandIdle);
const name = who === "farstring" ? "妮娅" : "赛布尔";
const role = who === "farstring" ? "弓手" : "治疗";
return `
<section class="actor actor-${who} ${urgent ? "urgent" : ""} layer-actors" style="--x:${x}px;--y:${y}px" aria-label="${order} ${name} ${role} 生命 ${hp}">
<div class="actor-badge"><b>${order}</b><span>${label}</span></div>
<img src="${actorSrc}" alt="${name} · ${role}">
<div class="actor-meta">
<strong>${name} · ${role}</strong>
<span class="hp"><i style="--hp:${hp}%"></i></span>
<small>${hp} / 100${urgent ? ` · ${urgent}` : ""}</small>
</div>
</section>`;
}
function enemy(hp = 100, urgent = "") {
return `
<section class="enemy ${urgent ? "urgent" : ""} layer-actors" aria-label="裂影刃手 生命 ${hp}">
<div class="enemy-name"><strong>裂影刃手</strong><span>路线守卫</span></div>
${img(ASSETS.enemy, "enemy-sprite", "裂影刃手")}
<span class="hp enemy-hp"><i style="--hp:${hp}%"></i></span>
<small>${hp} / 100${urgent ? ` · ${urgent}` : ""}</small>
</section>`;
}
function battlefieldBase(title, objective) {
return `
${statusRail(title, true)}
<section class="battlefield layer-environment">
<img class="field-art" src="${ASSETS.field}" alt="断裂风路遗迹战场">
<div class="field-wash"></div>
<div class="objective layer-ui"><span>当前目标</span><strong>${objective}</strong></div>
<div class="party-ground layer-connectors"><i></i><i></i></div>
</section>`;
}
function eventRibbon(step, text, source, action, target, result) {
return `
<footer class="event-ribbon layer-captions">
<span class="event-step">事件 ${step}</span>
<strong>${text}</strong>
<div class="event-chain"><span>${source}</span><i>→</i><span>${action}</span><i>→</i><span>${target}</span><i>→</i><b>${result}</b></div>
</footer>`;
}
function formationReview() {
return `
${statusRail("系统编队 · 检查两人的职责")}
<section class="formation-world layer-environment">
<img class="field-art" src="${ASSETS.field}" alt="断裂风路遗迹">
<div class="formation-wash"></div>
<div class="formation-cord layer-connectors"><span></span></div>
${actor({who:"kindhand", action:"full", x:64, y:82, order:"1", label:"第二位", hp:100})}
${actor({who:"farstring", action:"full", x:282, y:74, order:"0", label:"先行位", hp:100, urgent:"暴露"})}
<div class="threat-gap layer-connectors"><span>敌人方向</span><i></i></div>
<div class="formation-note note-second layer-ui"><b>第二位 · 1</b><span>受击后举灯,为先行者恢复生命</span></div>
<div class="formation-note note-lead layer-ui"><b>先行位 · 0</b><span>先接触威胁,以弓术回应敌人</span></div>
</section>
<aside class="context-rail layer-ui">
<span class="rail-kicker">系统排序原因</span>
<h2>职责决定顺序</h2>
<p>妮娅先接近威胁;赛布尔留在受保护位置,等待实际受伤目标。</p>
<div class="evidence-row"><i class="symbol order">0</i><span><b>妮娅 · 弓手</b>远程职责先行,但会承受更高暴露。</span></div>
<div class="evidence-row"><i class="symbol heal">+</i><span><b>赛布尔 · 治疗</b>第二位行动,恢复实际受伤成员。</span></div>
<p class="system-note">系统依据两人的当前职责形成此顺序。</p>
</aside>
<footer class="action-dock layer-ui">
<button class="secondary">重新选择成员</button>
<span>选择顺序不会改变系统编队</span>
<button class="primary">${icon("arrow")}确认编队并出发</button>
</footer>`;
}
function approachContact() {
return `
${battlefieldBase("第一战 · 接敌", "守住断裂风路 · 读取敌人锁定")}
${actor({who:"kindhand", x:112, y:137, order:"1", label:"第二位", hp:100})}
${actor({who:"farstring", x:270, y:125, order:"0", label:"先行位", hp:100, urgent:"被锁定"})}
${enemy(100, "蓄势")}
<div class="target-line layer-connectors"><span>锁定先行者</span></div>
<div class="threat-mark layer-feedback"><i></i><i></i><span>!</span></div>
${eventRibbon("01 · 1.20s", "危险:裂影刃手锁定妮娅", "裂影刃手", "重击预兆", "妮娅 · 先行位", "即将命中")}`;
}
function damageFeedback() {
return `
${battlefieldBase("第一战 · 敌方压力", "确认受击对象与生命变化")}
${actor({who:"kindhand", x:112, y:137, order:"1", label:"第二位", hp:100})}
${actor({who:"farstring", x:270, y:125, order:"0", label:"先行位", hp:58, urgent:"高暴露"})}
${enemy(100, "攻击已结算")}
<div class="impact-line layer-connectors"></div>
<div class="damage-burst layer-feedback">${img(ASSETS.damage, "vfx", "重击伤害")}</div>
<div class="number damage-number layer-feedback"><b>-42</b><span>100 → 58</span></div>
${eventRibbon("02 · 2.35s", "妮娅在先行位承受高额伤害", "裂影刃手", "裂隙重击", "妮娅", "-42 · 58/100")}`;
}
function healResponse() {
return `
${battlefieldBase("第一战 · 职业回应", "赛布尔恢复实际受伤的妮娅")}
${actor({who:"kindhand", action:"heal", x:112, y:137, order:"1", label:"第二位", hp:100, urgent:"正在恢复"})}
${actor({who:"farstring", x:270, y:125, order:"0", label:"先行位", hp:70, urgent:"恢复后"})}
${enemy(100, "等待")}
<div class="heal-path layer-connectors"><i></i><i></i><i></i></div>
<div class="heal-burst layer-feedback">${img(ASSETS.heal, "vfx", "恢复效果")}</div>
<div class="number heal-number layer-feedback"><b>+12</b><span>58 → 70</span></div>
${eventRibbon("03 · 3.60s", "赛布尔从第二位举灯恢复妮娅", "赛布尔", "灵灯恢复", "妮娅", "+12 · 70/100")}`;
}
function rangedFinish() {
return `
${battlefieldBase("第一战 · 两人连携", "妮娅完成本轮远程回应")}
${actor({who:"kindhand", x:112, y:137, order:"1", label:"第二位", hp:100})}
${actor({who:"farstring", action:"shot", x:270, y:125, order:"0", label:"先行位", hp:70, urgent:"远程射击"})}
${enemy(34, "即将倒下")}
<div class="projectile-line layer-connectors"><span></span></div>
<div class="number enemy-number layer-feedback"><b>-30</b><span>64 → 34</span></div>
${eventRibbon("04 · 5.10s", "妮娅以弓术把敌人压入终结线", "妮娅", "远程射击", "裂影刃手", "-30 · 34/100")}`;
}
function firstDiagnosis() {
return `
${statusRail("第一战诊断 · 这支小队如何配合")}
<section class="result-world layer-environment">
<img class="field-art" src="${ASSETS.field}" alt="战后断裂风路">
<div class="result-wash"></div>
<div class="result-party layer-actors">
${img(ASSETS.kindhand, "result-kindhand", "赛布尔 · 治疗")}
${img(ASSETS.farstring, "result-farstring", "妮娅 · 弓手")}
<span class="result-order second">1 · 第二位</span>
<span class="result-order lead">0 · 先行位</span>
</div>
<div class="cause-path layer-connectors">
<div class="cause-node decision"><span>系统编队</span><b>妮娅先行</b><small>远程职责,但高暴露</small></div>
<i></i>
<div class="cause-node divergence"><span>最早差异 · 2.35s</span><b>重击 -42</b><small>妮娅 100 → 58</small></div>
<i></i>
<div class="cause-node response"><span>职业回应 · 3.60s</span><b>恢复 +12</b><small>妮娅 58 → 70</small></div>
</div>
</section>
<aside class="context-rail result-rail layer-ui">
<span class="rail-kicker">首战因果结果</span>
<h2>治疗补回部分损失</h2>
<dl class="result-copy">
<div><dt>发生了什么</dt><dd>系统让妮娅先行;她承受重击后完成远程回应。</dd></div>
<div><dt>为什么</dt><dd>赛布尔随后为实际受伤的妮娅恢复 12 生命。</dd></div>
<div><dt>系统编队造成的差异</dt><dd>远程成员先行而高暴露;治疗只补回部分损失。</dd></div>
<div><dt>现在改变了</dt><dd>妮娅 70/100;金币 0 → 20(仅一次)。</dd></div>
<div><dt>下一步</dt><dd>比较安全与节奏两种应对方案。</dd></div>
</dl>
</aside>
<footer class="action-dock result-dock layer-ui">
<button class="secondary">${icon("inspect")}查看完整事件</button>
<span class="receipt">${icon("check")}奖励已结算一次 · 金币 +20</span>
<button class="primary">${icon("arrow")}查看下一场威胁</button>
</footer>`;
}
const renderers = {
"formation-review": formationReview,
"approach-contact": approachContact,
"damage-feedback": damageFeedback,
"heal-response": healResponse,
"ranged-finish": rangedFinish,
"first-diagnosis": firstDiagnosis,
};
const root = document.querySelector("#slice");
root.dataset.frame = selectedFrame;
root.innerHTML = renderers[selectedFrame]();
document.documentElement.dataset.ready = "true";
+73
View File
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { chromium } from "playwright";
import { createServer } from "node:http";
const root = fileURLToPath(new URL(".", import.meta.url));
const manifest = JSON.parse(await readFile(join(root, "frame-manifest.json"), "utf8"));
const assets = JSON.parse(await readFile(join(root, "asset-manifest.json"), "utf8"));
const errors = [];
const hashes = {};
for (const asset of assets.assets) {
const local = await readFile(join(root, asset.path));
const source = await readFile(join(root, "../../../..", asset.source));
const localHash = createHash("sha256").update(local).digest("hex");
const sourceHash = createHash("sha256").update(source).digest("hex");
hashes[asset.path] = { source: asset.source, sha256: localHash };
if (localHash !== sourceHash) errors.push(`source hash mismatch: ${asset.path}`);
}
const mime = { ".html":"text/html", ".css":"text/css", ".js":"text/javascript", ".png":"image/png", ".ttf":"font/ttf" };
const server = createServer(async (request, response) => {
const rawPath = decodeURIComponent((request.url || "/").split("?")[0]);
const relativePath = rawPath === "/" ? "index.html" : rawPath.replace(/^\//, "");
try {
const { extname } = await import("node:path");
const body = await readFile(join(root, relativePath));
response.writeHead(200, { "content-type": mime[extname(relativePath)] || "application/octet-stream" });
response.end(body);
} catch { response.writeHead(404); response.end("Not found"); }
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 844, height: 390 }, deviceScaleFactor: 1 });
for (const frame of manifest.frames) {
await page.goto(`http://127.0.0.1:${port}/index.html?frame=${frame.id}`, { waitUntil: "networkidle" });
const state = await page.evaluate(() => {
const slice = document.querySelector("#slice");
const nodes = [...document.querySelectorAll("body *")];
const overflow = nodes.filter(node => {
const r = node.getBoundingClientRect();
return r.width > 0 && r.height > 0 && (r.left < -0.5 || r.top < -0.5 || r.right > 844.5 || r.bottom > 390.5);
}).map(node => `${node.tagName}.${node.className}`);
return {
ready: document.documentElement.dataset.ready,
frame: slice?.dataset.frame,
overflow,
actors: document.querySelectorAll(".layer-actors img").length,
text: document.body.innerText
};
});
if (state.ready !== "true" || state.frame !== frame.id) errors.push(`frame not ready: ${frame.id}`);
if (state.overflow.length) errors.push(`viewport overflow ${frame.id}: ${state.overflow.join(", ")}`);
if (state.actors < 2) errors.push(`missing actors: ${frame.id}`);
if (!state.text.includes("妮娅") || !state.text.includes("赛布尔")) errors.push(`identity copy missing: ${frame.id}`);
}
await browser.close();
server.close();
const generation = JSON.parse(await readFile(join(root, "generation-record.json"), "utf8"));
if (generation.paid_calls_used > generation.authorized_call_ceiling) errors.push("paid call ceiling exceeded");
if (generation.returned_cost_usd > generation.authorized_cost_ceiling_usd) errors.push("paid cost ceiling exceeded");
const report = { ok: errors.length === 0, viewport: manifest.viewport, frame_count: manifest.frames.length, asset_count: assets.assets.length, paid_calls_used: generation.paid_calls_used, paid_cost_usd: generation.returned_cost_usd, errors, hashes };
await writeFile(join(root, "evidence", "verification.json"), JSON.stringify(report, null, 2) + "\n");
console.log(JSON.stringify(report, null, 2));
if (errors.length) process.exitCode = 1;