From 64eb7a4eacf718c0f79f199e8228dd02c0f217e9 Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 3 Jul 2026 14:01:02 -0700 Subject: [PATCH] =?UTF-8?q?feat(gate-fixtures):=20=E5=8D=A1=20menu=20?= =?UTF-8?q?=E6=97=8F=20fx4c=C3=973=20=E5=8D=87=E4=BA=A7=E7=89=A9=E5=9E=8B?= =?UTF-8?q?=E5=88=A4=E4=BE=8B=20+=20=E5=89=A7=E6=83=85=E9=87=91=E6=A0=87?= =?UTF-8?q?=E6=AD=A3=E4=BE=8B=20p11a-s1=20=E5=9B=BA=E5=8C=96=E5=85=A5?= =?UTF-8?q?=E4=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 判例库补两类被门证实的证据落位,门判据改动前的全量回归多两组锚: - 卡 menu 族 amgen-fx4c-{1,3,4}(复现窗 2026-07-03)从 pending 升为 product_fixtures: 每份固化 src 源码(卡 menu 现场:menuStartBtn 仅 render 副作用赋值 + 非方向键起局)+ evidence 摘要;bundle/index.html 构建产物与约 1MB 生成 trace(verdict 全 null)不入仓, 附 tools.mjs build-saa 重建命令。裁决两防线:案 1/3 驱动器约定漂移(误杀)、案 4 并发 flaky(脆弱写法),细化归卡 menu 归因单。 - 剧情品类金标正例 p11a-s1「深渊回声」(丰富度 7/11、九门全绿)落 cheap-worker/fixtures/golden-samples/narrative/,给策划知识包 v2 正例复验当靶子; narrative.json 的 _note 指针从「产物未入仓」翻为已固化。 manifest 新增 product_fixtures 键表达「有载体、真跑需九门 CDP 环境」的第三态(区别 cases 可运行 / pending 无载体);产物型判例不进 run.mjs 零依赖 stage。run.mjs 全量回归绿 (node 三段 29 过、python 段无 pytest 合法 SKIP)。 Co-Authored-By: Claude Fable 5 --- .../fixtures/genre-rubrics/narrative.json | 2 +- .../fixtures/golden-samples/README.md | 16 + .../p11a-s1/evidence/run-summary.json | 274 +++++++++ .../narrative/p11a-s1/src/assets.js | 73 +++ .../narrative/p11a-s1/src/core.js | 206 +++++++ .../narrative/p11a-s1/src/game-logic.js | 288 +++++++++ .../narrative/p11a-s1/src/game.js | 73 +++ .../narrative/p11a-s1/src/host-config.js | 110 ++++ .../narrative/p11a-s1/src/render.js | 349 +++++++++++ contracts/gate-fixtures/README.md | 8 +- contracts/gate-fixtures/manifest.json | 38 +- contracts/gate-fixtures/products/README.md | 26 + .../evidence/service-run-summary.json | 12 + .../products/amgen-fx4c-1/src/assets.js | 73 +++ .../products/amgen-fx4c-1/src/core.js | 220 +++++++ .../products/amgen-fx4c-1/src/game-logic.js | 556 ++++++++++++++++++ .../products/amgen-fx4c-1/src/game.js | 73 +++ .../products/amgen-fx4c-1/src/host-config.js | 110 ++++ .../products/amgen-fx4c-1/src/render.js | 331 +++++++++++ .../evidence/service-run-summary.json | 12 + .../products/amgen-fx4c-3/src/assets.js | 73 +++ .../products/amgen-fx4c-3/src/core.js | 148 +++++ .../products/amgen-fx4c-3/src/game-logic.js | 431 ++++++++++++++ .../products/amgen-fx4c-3/src/game.js | 73 +++ .../products/amgen-fx4c-3/src/host-config.js | 110 ++++ .../products/amgen-fx4c-3/src/render.js | 235 ++++++++ .../evidence/service-run-summary.json | 12 + .../products/amgen-fx4c-4/src/assets.js | 73 +++ .../products/amgen-fx4c-4/src/core.js | 69 +++ .../products/amgen-fx4c-4/src/game-logic.js | 544 +++++++++++++++++ .../products/amgen-fx4c-4/src/game.js | 73 +++ .../products/amgen-fx4c-4/src/host-config.js | 110 ++++ .../products/amgen-fx4c-4/src/render.js | 254 ++++++++ 33 files changed, 5049 insertions(+), 6 deletions(-) create mode 100644 cheap-worker/fixtures/golden-samples/README.md create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/evidence/run-summary.json create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/assets.js create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/core.js create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game-logic.js create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game.js create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/host-config.js create mode 100644 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/render.js create mode 100644 contracts/gate-fixtures/products/README.md create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/evidence/service-run-summary.json create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/src/assets.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/src/core.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/src/game-logic.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/src/game.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/src/host-config.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-1/src/render.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/evidence/service-run-summary.json create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/src/assets.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/src/core.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/src/game-logic.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/src/game.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/src/host-config.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-3/src/render.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/evidence/service-run-summary.json create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/src/assets.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/src/core.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/src/game-logic.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/src/game.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/src/host-config.js create mode 100644 contracts/gate-fixtures/products/amgen-fx4c-4/src/render.js diff --git a/cheap-worker/fixtures/genre-rubrics/narrative.json b/cheap-worker/fixtures/genre-rubrics/narrative.json index 5e948b23..26a9459f 100644 --- a/cheap-worker/fixtures/genre-rubrics/narrative.json +++ b/cheap-worker/fixtures/genre-rubrics/narrative.json @@ -1,6 +1,6 @@ { "genre": "narrative", - "_note": "剧情品类丰富度 rubric 扩展(W-GENRE 品类件④·机器版)。喂 cheap_verify 的 LLM 验证 agent:与通用底座 11 条同一次评分调用、同一 0/1+层标形状,但分母独立小计(质量模型 SoT §3.3 规范二,不与底座混合平均);纯 LLM 非阻塞,绝不写成代码校验/不进九门/不进脚手架。2026-07-04 数据面补全(策划知识包 v2):五条自 .agents/skills/narrative-game-design.md §10 品类 rubric 表(剧情品类件 e2d3a0a0 双评审原文)逐字迁入,判据/正反例一字不动,skill 表同步改指针指向本文件(单源,消双写);接线键=narrative(cheap_verify.GENRE_BY_TEMPLATE: _template-story→narrative)。金标锚(规范三):薄反例=_template-story 骨架原样(在仓,首批实测基线 3/11:L2 1/6·L3 0/3·L4 2/2);正例=首批小批 p11a-s1「深渊回声」(实测基线 7/11:L2 4/6·L3 1/3·L4 2/2,产物未入仓——固化入仓后方可跑正例复验,处理同判例库 fx4c pending;此前本品类评分产出仅观测)。真 LLM 金标复验挂 mini-desktop 窗待跑(单款分组小计漂移 >±1 即回退)。", + "_note": "剧情品类丰富度 rubric 扩展(W-GENRE 品类件④·机器版)。喂 cheap_verify 的 LLM 验证 agent:与通用底座 11 条同一次评分调用、同一 0/1+层标形状,但分母独立小计(质量模型 SoT §3.3 规范二,不与底座混合平均);纯 LLM 非阻塞,绝不写成代码校验/不进九门/不进脚手架。2026-07-04 数据面补全(策划知识包 v2):五条自 .agents/skills/narrative-game-design.md §10 品类 rubric 表(剧情品类件 e2d3a0a0 双评审原文)逐字迁入,判据/正反例一字不动,skill 表同步改指针指向本文件(单源,消双写);接线键=narrative(cheap_verify.GENRE_BY_TEMPLATE: _template-story→narrative)。金标锚(规范三):薄反例=_template-story 骨架原样(在仓,首批实测基线 3/11:L2 1/6·L3 0/3·L4 2/2);正例=首批小批 p11a-s1「深渊回声」(实测基线 7/11:L2 4/6·L3 1/3·L4 2/2,已固化入仓 cheap-worker/fixtures/golden-samples/narrative/p11a-s1/=src+evidence/run-summary.json[九门全绿+丰富度逐条],正例复验可据此起跑;同批判例库卡 menu 族 fx4c 已升 gate-fixtures/manifest.json 的 product_fixtures;此前本品类评分产出仅观测)。真 LLM 金标复验挂 mini-desktop 窗待跑(单款分组小计漂移 >±1 即回退)。", "items": [ { "name": "选择有重量", diff --git a/cheap-worker/fixtures/golden-samples/README.md b/cheap-worker/fixtures/golden-samples/README.md new file mode 100644 index 00000000..729b4e6f --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/README.md @@ -0,0 +1,16 @@ +# golden-samples —— 品类金标正例产物 + +这里是各品类的**正例基准**:一款被九门判过、丰富度评过分的完整游戏工程,给「策划知识包」的正例复验当靶子。它和同级的两件东西分工不同——`../golden-specs/` 是门要核对的 play-spec 考卷,`../genre-rubrics/` 是喂 LLM 打分的丰富度评分尺,而这里是「一款好游戏长什么样」的实物样本。 + +## narrative(剧情)`p11a-s1`「深渊回声」 + +策划知识包 v2 首批小批里跑出来的剧情正例:单人潜艇下潜查失联科考站,14 节点 / 5 结局、冷静与胆识双属性。丰富度实测 7/11(L2 4/6·L3 1/3·L4 2/2),对照薄反例 `_template-story` 骨架原样的 3/11——正例复验就是看新生成的剧情游戏能不能稳定接近这条正例线。 + +留了什么: + +- `src/` —— 六个源文件,游戏本体。 +- `evidence/run-summary.json` —— 完整取证:九门全绿(latch 驻留 600ms、真玩 score 0→6)、丰富度 7/11 的逐条命中理由、模型与成本(MiniMax-M3,¥1.2475)。这份就是它作为「金标」的凭据。 + +没留 `bundle.iife.js` 构建产物、`index.html`、生成 `trace.jsonl` 与空 `assets/`(同判例库 `contracts/gate-fixtures/products/` 的口径:只固化源码证据 + 取证摘要)。 + +指针登记在 `../genre-rubrics/narrative.json` 的 `_note`。剧情品类的 play-spec 考卷另见 `../golden-specs/story-branch.play-spec.json`。 diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/evidence/run-summary.json b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/evidence/run-summary.json new file mode 100644 index 00000000..bf6c8d6a --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/evidence/run-summary.json @@ -0,0 +1,274 @@ +{ + "ok": true, + "gameId": "p11a-s1", + "brief": "深海科考站失联了,你乘单人潜艇下去查明真相", + "model": "MiniMax-M3", + "finished": true, + "attempts": 1, + "check": { + "ok": true + }, + "build": { + "ok": true + }, + "staged": true, + "smoke": { + "ok": true + }, + "verdict": { + "pass": true, + "failedGates": [] + }, + "breaker": null, + "costRmb": 1.2475, + "rmbGate": "active", + "tokens": { + "in": 510349, + "out": 14824, + "total": 525173 + }, + "doneSummary": "做了一款深海剧情互动游戏「深渊回声」:单人潜艇下潜 1800 米去失联科考站「海渊-07」查明真相——14 节点 / 5 结局(2 胜含 1 隐藏真结局 / 3 负)、冷静与胆识双属性、可携水晶样本检定,深海观感(深度计/声呐脉冲/浮游生物/下层生物剪影/琥珀舱卡)+ 结局图鉴跨局持久。", + "wallSec": 217.3, + "trace": { + "traceId": "p11a-s1", + "steps": 271, + "dropped": 0, + "middlewareDropped": 0 + }, + "verdictFull": { + "gameId": "p11a-s1", + "url": "http://localhost:4341/games/_wg1-gen/p11a-s1/index.html", + "ts": null, + "guards": { + "A_boot": { + "pass": true + }, + "C_frame": { + "pass": true, + "f0": 56, + "f1": 86, + "delta": 30 + }, + "I_control": { + "pass": true, + "skipped": "未声明 controlCheck(向后兼容)" + }, + "D_render": { + "pass": true, + "bright": 93100, + "maxCh": 255 + }, + "E_live": { + "pass": true, + "played": true, + "phase1": "over", + "distinctStates": 9, + "samples": 9, + "liveByState": true + }, + "B_uncaught": { + "pass": true, + "uncaught": [] + }, + "F_wiring": { + "pass": true, + "callCount": 5840, + "sample": [ + "save.get", + "sceneFsm.define", + "sceneFsm.define", + "sceneFsm.define", + "sceneFsm.start", + "sceneFsm.update", + "sceneFsm.render", + "hudUi.drawText", + "hudUi.drawText", + "hudUi.drawText", + "hudUi.drawButton", + "juice.render" + ], + "expected": [ + "sessionScore.", + "audioMusic.", + "juice." + ] + }, + "G_input": { + "pass": true, + "hashDiffer": true, + "played": true, + "inputHash": "bb783993", + "controlHash": "c3769589", + "atFrame": 320, + "ctrlFrame": 322 + }, + "H_progress": { + "pass": true, + "checks": [ + { + "path": "score", + "op": "increased", + "before": 0, + "after": 6, + "pass": true, + "why": "真玩应加分" + } + ], + "latch": { + "pass": true, + "dwelledMs": 600, + "after": "over", + "reason": "" + }, + "state0": { + "phase": "menu", + "score": 0, + "best": 0, + "outcome": "playing", + "node": "start", + "stats": { + "composure": 0, + "courage": 0 + }, + "endingsUnlocked": 0, + "endingsTotal": 6, + "targets": [] + }, + "state1": { + "phase": "over", + "score": 6, + "best": 6, + "outcome": "won", + "node": "ending_truth", + "stats": { + "composure": 6, + "courage": 0 + }, + "endingsUnlocked": 1, + "endingsTotal": 6, + "targets": [] + } + } + }, + "pass": true, + "notes": [], + "advisoryGates": [], + "firstPlay": { + "enabled": true, + "categoryDerived": "placement", + "playableMs": 57, + "playableThresholdMs": 2000, + "warmupMs": 202, + "firstFeedback": null, + "firstMove": null, + "notes": [ + "playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。" + ], + "playableOk": true, + "playableScope": "warm", + "firstFeedbackSkipped": "driver 缺/无可驱动合法首招", + "coreLoopReached": true, + "coreLoopElapsedMs": 10587, + "coreLoopThresholdMs": 60000, + "pass": true + } + }, + "driverType": "tap-targets", + "models": { + "code": "MiniMax-M3" + }, + "stage": "play", + "richness": { + "score": 7, + "max": 11, + "hits": [ + { + "name": "即时反馈", + "layer": "L2", + "hit": true, + "why": "choose() 里调用 juice.burst(选项中心点) 迸发粒子,并 playSfx('click'),得分 +1,选项按下去即有粒子+音效+计分三重即时回报。" + }, + { + "name": "可见成长", + "layer": "L2", + "hit": true, + "why": "顶栏有深度米数随节点推进加深、冷静/胆识属性条可见累计、score 随选择数单调上升,数字与规模肉眼可见地变大。" + }, + { + "name": "下一个解锁", + "layer": "L3", + "hit": false, + "why": "结构是节点→选项→结局,每次只有两条路径分叉,没有'再攒 N 分/再走 K 步解锁 X'的进度钩子,真结局需靠路径而非可见门槛触发。" + }, + { + "name": "30秒爽点", + "layer": "L2", + "hit": true, + "why": "开局即可点 start 进入 play,首次选择就有 juice 粒子+音效+score+1,属性条立刻 +1,30 秒内必有第一次'拿分+爆破'的爽点。" + }, + { + "name": "数值滚雪球", + "layer": "L2", + "hit": false, + "why": "score = 选择数,每步稳定 +1,属性每步稳定 +1,完全是线性增长,没有任何暴富段或加速曲线。" + }, + { + "name": "情感锚", + "layer": "L2", + "hit": false, + "why": "没有可养成的萌角色或拥有物,'水晶样本'只是剧情徽章不可养成,玩家与角色/物品之间缺少'想养大它'的羁绊。" + }, + { + "name": "放置回归", + "layer": "L3", + "hit": false, + "why": "剧情互动品类,无离线收益计算也无回弹窗,save 只持久化已解锁结局列表,离线回来没有惊喜。" + }, + { + "name": "音反馈", + "layer": "L2", + "hit": true, + "why": "点击选项 playSfx('click')、结局 playSfx('win'/'fail')、菜单开始也 playSfx('click'),关键节点全部有程序化音效。" + }, + { + "name": "首3分钟脚本", + "layer": "L3", + "hit": true, + "why": "0–10s 点 start 进剧情零阅读上手,10–60s 第一次选择即有反馈,3~5 步内即触结局,首个结局后 over 屏露出 x/N + ??? 隐藏结局钩,首 3 分钟脚本成立。" + }, + { + "name": "炫耀时刻", + "layer": "L4", + "hit": true, + "why": "over 屏由 renderOver 渲染,展示结局标题、结局文案、x/N 图鉴、属性结算、restart 按钮,可截图分享,非直接黑屏。" + }, + { + "name": "同款钩子", + "layer": "L4", + "hit": true, + "why": "有清晰的 STORY_TITLE/SUBTITLE/STAT_NAMES/START_NODE/14 节点+5 结局题材元数据,题材标签(深海/科考/单人潜艇)鲜明,remux 预填即可做同款。" + } + ], + "groups": { + "L2": { + "score": 4, + "max": 6 + }, + "L3": { + "score": 1, + "max": 3 + }, + "L4": { + "score": 2, + "max": 2 + } + }, + "notes": "作为剧情互动品类骨架扎实、品质在线,但成长曲线线性、无养成锚点、无离线收益,更像一次性完整体验而非耐玩小游戏。", + "degraded": false, + "judgeTokens": { + "in": 7606, + "out": 614, + "total": 8220 + } + } +} \ No newline at end of file diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/assets.js b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/assets.js @@ -0,0 +1,73 @@ +/** + * assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件) + * owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取) + * + * 【本款现状:无外部资产(纯程序化图形)】 + * 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。 + * 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键, + * render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。 + * + * 【容错铁律(发行版红线 §7-5)】 + * 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退), + * 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。 + * node 环境无 Image → 同样安全返回空集(import-safe)。 + * 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。 + */ + +'use strict'; + +/** + * 图像资产键 → 文件名(与 assets/manifest.json 对应)。 + * 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。 + * 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。 + * @type {Record} + */ +export const IMAGE_FILES = {}; + +/** + * 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。 + * @param {string} [baseUrl] 资产根(缺省 './assets/') + * @returns {Promise<{ images: Record, missing: string[], loaded: number, total: number }>} + */ +export function loadAssets(baseUrl) { + const root = baseUrl == null ? './assets/' : baseUrl; + const keys = Object.keys(IMAGE_FILES); + /** @type {Record} */ + const images = {}; + /** @type {string[]} */ + const missing = []; + + /** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */ + function loadOne(key) { + return new Promise((resolve) => { + // 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。 + if (typeof Image === 'undefined') { + images[key] = null; + missing.push(key); + resolve(null); + return; + } + const img = new Image(); + img.onload = () => { + images[key] = img; + resolve(img); + }; + img.onerror = () => { + // 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。 + console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]); + images[key] = null; + missing.push(key); + resolve(null); + }; + img.src = root + IMAGE_FILES[key]; + }); + } + + // 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。 + return Promise.all(keys.map(loadOne)).then(() => ({ + images, + missing, + loaded: keys.length - missing.length, + total: keys.length, + })); +} diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/core.js b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/core.js new file mode 100644 index 00000000..c8c29c23 --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/core.js @@ -0,0 +1,206 @@ +/** + * core.js — 【L3 纯逻辑】剧情互动品类 · 「深渊回声」的故事数据与判定 + * + * 题材:深海科考站「海渊-07」失联,你乘单人潜艇「白鲸-IV」下潜 1800 米查明真相。 + * 结构:读场景文本 → 2 选项 → 推进 → 多结局图鉴(2 胜 3 负,含 1 隐藏真结局)。 + * + * 节点字段(公开契约,render / game-logic / 取证共用): + * 普通节点 { text, choices:[{ label, next, effects:{属性:+n} }] }(choices 2 个) + * 结局节点 { ending:true, outcome:'win'|'lose', title, text } + * + * 设计要点: + * · 属性双轴「冷静 / 胆识」—— 深海题材最自然的张力 + * · 「sample」位标志:你捡到了一块来自深渊的水晶样本(走 hull_check→lab_quirk 路径); + * 拿到样本后部分结局文本会提到它(实质是检定通过的真结局,路径稍隐蔽) + * · 5 结局:ending_savior(win,常规)/ ending_truth(win,隐藏,需冷静≥3或携 sample)/ + * ending_cynic(lose)/ panic_attack(lose)/ weeper_chase(lose) / abandon_station(lose) + * = 2 win + 3 lose,达标 + * · 14 节点、深度 3~5 步、零回环、零死链、首节点即分叉 + */ + +'use strict'; + +/* ═══════════════════════════════════════════════════════════════════════════ + * 【多样性参数空间 · 这是「故事身份」,换题材就改这一整段】 + * ═══════════════════════════════════════════════════════════════════════════ */ + +export const STORY_TITLE = '深渊回声'; +export const STORY_SUBTITLE = '海渊-07 站失联六小时。你驾单人潜艇「白鲸-IV」下潜 1800 米,去找回那三个人。'; + +/** 属性双轴:冷静(细致/克制)+ 胆识(果断/冒险)。 */ +export const STAT_NAMES = { composure: '冷静', courage: '胆识' }; + +/** 起始节点 id。 */ +export const START_NODE = 'start'; + +/** + * 故事节点图 + * 写图三铁律:① 每个 next 必须真存在 ② 无回环(任一路径有限步必达结局)③ 结局 ≥3 且有 win 有 lose + * 文本红线:text ≤3 短句(≤60 字)、choice.label ≤10 字。 + */ +export const STORY_NODES = { + // ───── 0 起点(第一分叉) ───── + start: { + text: '下潜至 1200 米,声呐显示站内三个生命信号都还活着。艇壳吱吱响,主对接舱就在眼前。', + choices: [ + { label: '绕站一圈侦察', next: 'hull_check', effects: { composure: 1 } }, + { label: '直接对接主舱门', next: 'airlock', effects: { courage: 1 } }, + ], + }, + + // ───── 侦察分支 ───── + hull_check: { + text: '观察窗外,观察窗的裂缝从内侧炸开,不是从外——有东西是从里面出来的。', + choices: [ + { label: '机械臂取样', next: 'lab_quirk', effects: { composure: 1 } }, + { label: '先救人', next: 'airlock', effects: { courage: 1 } }, + ], + }, + lab_quirk: { + text: '裂缝边挂着一小块水晶:它在你手里低频震颤,像在听。把它扔掉还是带进站?', + choices: [ + { label: '带去中控', next: 'log_split', effects: { composure: 1 } }, + { label: '扔回海里', next: 'abandon_station', effects: { courage: 1 } }, + ], + }, + + // ───── 进入主站(汇流点) ───── + airlock: { + text: '对接成功。站内应急灯一明一灭,主控台最后一条记录停在六小时前。', + choices: [ + { label: '调出主控日志', next: 'log_split', effects: { composure: 1 } }, + { label: '喊一声看看', next: 'corridor_dark', effects: { courage: 1 } }, + ], + }, + log_split: { + text: '末条日志:「09 号样本对声波有反应。我们把它叫醒了。」下层实验室是源头。', + choices: [ + { label: '下到下层实验室', next: 'lower_lab', effects: { composure: 1 } }, + { label: '先去休息舱', next: 'corridor_dark', effects: { courage: 1 } }, + ], + }, + + // ───── 下层实验室(尽头) ───── + lower_lab: { + text: '09 号压力罐空了,角落的黑色轮廓在动,它没攻击——它在等。你要打破沉默吗?', + choices: [ + { label: '静静观察', next: 'crystal_truth', effects: { composure: 1 } }, + { label: '打开强光手电', next: 'panic_attack', effects: { courage: 1 } }, + ], + }, + crystal_truth: { + text: '手电不开,水面反而澄清。它在海底亿万年的沉默里学会的第一个词,是你心跳的频率。', + choices: [ + { label: '回传数据给母船', next: 'ending_truth', effects: { composure: 1 } }, + { label: '切断通讯', next: 'ending_cynic', effects: { courage: 1 } }, + ], + }, + + // ───── 休息舱分支(另一条汇聚) ───── + corridor_dark: { + text: '三人里两人面无表情盯着同一面墙,一个不见踪影。桌上笔记本:它会模仿声音,千万不要出声。', + choices: [ + { label: '关一切发声设备', next: 'silent_save', effects: { composure: 1 } }, + { label: '大声呼叫同事', next: 'weeper_chase', effects: { courage: 1 } }, + ], + }, + silent_save: { + text: '你用机械臂在地板上敲出摩尔斯,两人眨了眨眼,那条躲在通风管里的影子安静了。', + choices: [ + { label: '带他们撤回潜艇', next: 'ending_savior', effects: { composure: 1 } }, + ], + }, + + // ───── 五个结局 ───── + ending_savior: { + ending: true, outcome: 'win', title: '无声信使', + text: '三人全员回艇。母船新闻里你被称为「深渊最冷静的声音」,但你知道那只是摩尔斯。', + }, + ending_truth: { + ending: true, outcome: 'win', title: '回声破译者', + text: '数据回传,人类第一次与深渊生物完成对话。它给出的第一句完整语言,是你的名字。', + }, + ending_cynic: { + ending: true, outcome: 'lose', title: '缄默者', + text: '你掐断通讯,却发现自己的呼吸声也变了——它没走,只是换了个房间听。', + }, + panic_attack: { + ending: true, outcome: 'lose', title: '强光代价', + text: '光柱扫过,压力罐 09 爆裂,海水倒灌。你最后看见的是自己潜艇的尾灯,远远地,越来越小。', + }, + weeper_chase: { + ending: true, outcome: 'lose', title: '替声者', + text: '那声音学走了你的嗓音,从通风管里回答你:「我在这。」下一秒,灯全灭了。', + }, + abandon_station: { + ending: true, outcome: 'lose', title: '未竟之航', + text: '你回到母船,递上无样本的空报告。海渊-07 在身后的黑暗里继续沉默,六小时后,信号全灭。', + }, +}; + +/* ── 选项按钮布局常量(几何身份:想换排版改这里)── */ +export const CHOICE_W_RATIO = 0.8; +export const CHOICE_H = 62; +export const CHOICE_GAP = 16; +export const CHOICE_TOP_RATIO = 0.60; + +/* ═══════════════════════════════════════════════════════════════════════════ + * 【剧情推进判定 · 结构层 · 一般不动】 + * ═══════════════════════════════════════════════════════════════════════════ */ + +export function getNode(id) { + return (id && Object.prototype.hasOwnProperty.call(STORY_NODES, id)) ? STORY_NODES[id] : null; +} + +export function isEnding(node) { + return !!(node && node.ending === true); +} + +export function allEndingIds(nodes) { + const src = nodes || STORY_NODES; + return Object.keys(src).filter((id) => src[id] && src[id].ending === true); +} + +export function layoutChoices(viewW, viewH, count) { + const w = Math.round(viewW * CHOICE_W_RATIO); + const x = Math.round((viewW - w) / 2); + const top = Math.round(viewH * CHOICE_TOP_RATIO); + const rects = []; + for (let i = 0; i < count; i++) { + rects.push({ x, y: top + i * (CHOICE_H + CHOICE_GAP), w, h: CHOICE_H, choice: i }); + } + return rects; +} + +export function hitChoice(px, py, rects) { + if (!Array.isArray(rects)) return -1; + for (const r of rects) { + if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return r.choice; + } + return -1; +} + +export function applyEffects(stats, effects) { + const out = {}; + for (const k of Object.keys(stats || {})) out[k] = stats[k]; + for (const k of Object.keys(effects || {})) { + out[k] = (out[k] || 0) + effects[k]; + } + return out; +} + +export function initialStats() { + const out = {}; + for (const k of Object.keys(STAT_NAMES)) out[k] = 0; + return out; +} + +export function mergeEndings(unlocked, endingId, nodes) { + const valid = new Set(allEndingIds(nodes)); + const set = new Set(); + for (const id of (Array.isArray(unlocked) ? unlocked : [])) { + if (valid.has(id)) set.add(id); + } + if (valid.has(endingId)) set.add(endingId); + return [...set].sort(); +} diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game-logic.js b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game-logic.js new file mode 100644 index 00000000..00b60f2c --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game-logic.js @@ -0,0 +1,288 @@ +/** + * game-logic.js — 【L3 游戏本体】剧情互动品类 ·「深渊回声」(deep-sea narrative) + * owner:生成 agent | 消费方:game.js wrapper(L1)调本文件的 createGame。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 故事题材:海渊-07 深海科考站失联,你乘单人潜艇下去查明真相。 + * 玩法身份:读场景文本 → 2 选项 → 冷静/胆识属性累积 → 5 结局(2 胜 3 负,含隐藏真结局)。 + * + * 推进结构沿用模板骨架(节点→选项→分支→结局→图鉴 → over latch → 再读一次): + * · 节点推进:handleTap(play phase) 命中选项 → 选 1 步 → 计分 +1 + 属性结算 + 粒子/音 → 推进 + * · 多结局:走到 ending 节点 → sessionScore.win()/lose() 锁定 → over 屏(驻留,玩家点再潜一次才重开) + * · 结局图鉴:save 跨局持久 → over 屏露「x/N + ???」,藏真结局钩 + * · _forensicsView 契约:state() 实时算;play 态 targets 列每个选项中心点 + occupied=true; + * score 随推进单调上升;menu/over 态 targets 为空 → 自动验收不误点。 + * + * 增添的故事身份层: + * · `hasSample`:你拿到了一块会震颤的水晶样本(走 hull_check→lab_quirk 路径),在 play + * 屏右上角显示「◆ 水晶样本」徽章(检定通过的真结局钩)。 + * · `depth` / `signals`:每个节点定一个深度米数与生命信号状态,顶栏深度计与生命点。 + * · `t` 时间(毫秒,受控):菜单 / 玩中 / 结算都推,让声呐脉冲与浮游生物有活帧。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +import { + STORY_TITLE, STORY_SUBTITLE, STAT_NAMES, STORY_NODES, START_NODE, + getNode, isEnding, allEndingIds, layoutChoices, hitChoice, applyEffects, + initialStats, mergeEndings, +} from './core.js'; +import { renderMenu, renderPlay, renderOver } from './render.js'; +import { loadAssets } from './assets.js'; + +const SCENE_MENU = 'menu'; +const SCENE_PLAY = 'play'; +const SCENE_OVER = 'over'; + +const ENDINGS_SAVE_KEY = 'story.endings'; + +const PULSE_PERIOD_MS = 1600; + +/** + * 节点专属氛围元数据(故事身份层):用 id 当 key,缺省 = 1800m / 3 信号。 + * 顶层节点 = 抵达科考站(浅),越往下深度越大;信号掉队 = lose 结局的语义。 + */ +const NODE_META = { + start: { depth: 1200, signals: 3 }, + hull_check: { depth: 1400, signals: 3 }, + lab_quirk: { depth: 1400, signals: 3 }, + airlock: { depth: 1800, signals: 3 }, + log_split: { depth: 1800, signals: 3 }, + lower_lab: { depth: 1840, signals: 2 }, + crystal_truth: { depth: 1850, signals: 2 }, + corridor_dark: { depth: 1810, signals: 2 }, + silent_save: { depth: 1810, signals: 2 }, + // 结局节点:深度保留剧情最后位置,信号按结局语义 + ending_savior: { depth: 1800, signals: 3 }, + ending_truth: { depth: 1850, signals: 3 }, + ending_cynic: { depth: 1850, signals: 1 }, + panic_attack: { depth: 1840, signals: 0 }, + weeper_chase: { depth: 1810, signals: 0 }, + abandon_station: { depth: 1200, signals: 0 }, +}; +function metaOf(id) { return NODE_META[id] || { depth: 1800, signals: 3 }; } + +export function createGame({ plugins, bundle, viewport }) { + const { sceneFsm, sessionScore, hudUi, save, audioMusic, juice } = plugins; + + const VW = (viewport && viewport.w) || 390; + const VH = (viewport && viewport.h) || 844; + + let boot = null; + let ctx = null; + let assets = null; + let inited = false; + + // 实例运行态 + let nodeId = START_NODE; + let stats = initialStats(); + let stepCount = 0; + let endingNode = null; + let unlockedEndings = []; + let hasSample = false; // 故事身份:水晶样本(走 hull_check→lab_quirk 路径后置 true) + let elapsedMs = 0; // 受控累计毫秒(全相推,给声呐/浮游/剪影漂移用) + const endingIds = allEndingIds(STORY_NODES); + + let choiceRects = []; + let menuStartBtn = null; + let overRestartBtn = null; + + const measures = { frames: 0, taps: 0, choices: 0, endings: 0 }; + + function currentNode() { + const n = getNode(nodeId); + if (n) return n; + if (ctx) ctx.log('err', '节点不存在,回起点', { nodeId }); + nodeId = START_NODE; + return getNode(nodeId); + } + + function relayout() { + const n = currentNode(); + const count = (n && !isEnding(n) && Array.isArray(n.choices)) ? n.choices.length : 0; + choiceRects = layoutChoices(VW, VH, count); + } + + function enterPlay() { + sessionScore.reset(); + nodeId = START_NODE; + stats = initialStats(); + stepCount = 0; + endingNode = null; + hasSample = false; // 重置故事身份(水晶样本) + relayout(); + sceneFsm.transition(SCENE_PLAY); + if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } } + if (ctx) ctx.log('scene', 'play 开始阅读', { start: nodeId }); + } + + function choose(idx) { + const n = currentNode(); + const c = n && n.choices && n.choices[idx]; + if (!c) return; + measures.choices += 1; + stepCount += 1; + stats = applyEffects(stats, c.effects); + // 故事身份:effects 里 sample:1 标记拿到水晶(走 hull_check→lab_quirk) + if (c.effects && c.effects.sample) hasSample = true; + sessionScore.add(1); // score 随推进单调上升 → H 进展门 + const r = choiceRects[idx]; + if (juice && r) { try { juice.burst(r.x + r.w / 2, r.y + r.h / 2); } catch (_) { /* 容错 */ } } + if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } } + if (ctx) ctx.log('choice', '选择', { from: nodeId, pick: c.label, next: c.next, stats, hasSample }); + const nx = getNode(c.next); + if (nx && isEnding(nx)) { finishStory(c.next, nx); return; } + nodeId = nx ? c.next : START_NODE; + relayout(); + } + + function finishStory(id, node) { + measures.endings += 1; + endingNode = node; + nodeId = id; + if (node.outcome === 'win') sessionScore.win(); else sessionScore.lose(); + unlockedEndings = mergeEndings(unlockedEndings, id, STORY_NODES); + if (save) { try { save.set(ENDINGS_SAVE_KEY, unlockedEndings); } catch (_) { /* 容错 */ } } + if (audioMusic) { try { audioMusic.playSfx(node.outcome === 'win' ? 'win' : 'fail'); } catch (_) { /* 容错 */ } } + if (juice) { try { juice.burst(VW / 2, VH * 0.19); } catch (_) { /* 容错 */ } } + sceneFsm.transition(SCENE_OVER); + if (ctx) ctx.log('scene', 'over 结局', { ending: id, outcome: node.outcome, score: sessionScore.getScore(), unlocked: unlockedEndings.length }); + } + + function handleTap(x, y) { + measures.taps += 1; + const cur = sceneFsm.current(); + if (cur === SCENE_MENU) { + if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay(); + return; + } + if (cur === SCENE_OVER) { + if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay(); + return; + } + if (cur === SCENE_PLAY) { + const idx = hitChoice(x, y, choiceRects); + if (idx >= 0) choose(idx); + } + } + + function defineScenes() { + sceneFsm.define(SCENE_MENU, { + onEnter() { menuStartBtn = null; }, + render(g) { + menuStartBtn = renderMenu(g, hudUi, { + vw: VW, vh: VH, title: STORY_TITLE, subtitle: STORY_SUBTITLE, t: elapsedMs, + }).startBtn; + }, + }); + sceneFsm.define(SCENE_PLAY, { + render(g) { + const pulse = 0.5 + 0.5 * Math.sin((elapsedMs % PULSE_PERIOD_MS) / PULSE_PERIOD_MS * Math.PI * 2); + const meta = metaOf(nodeId); + renderPlay(g, hudUi, { + vw: VW, vh: VH, + node: currentNode(), + nodeId, + rects: choiceRects, + stats, statNames: STAT_NAMES, stepCount, pulse, + depth: meta.depth, signals: meta.signals, t: elapsedMs, hasSample, + }); + }, + }); + sceneFsm.define(SCENE_OVER, { + onEnter() { overRestartBtn = null; }, + render(g) { + overRestartBtn = renderOver(g, hudUi, { + vw: VW, vh: VH, endingNode, stats, statNames: STAT_NAMES, + unlocked: unlockedEndings, endingIds, nodes: STORY_NODES, t: elapsedMs, + }).restartBtn; + }, + }); + } + + /* ════════════ GameInstance 五法 ════════════ */ + const instance = { + async init(b) { + boot = b; + ctx = b.ctx; + assets = await loadAssets('./assets/'); + unlockedEndings = mergeEndings((save && save.get(ENDINGS_SAVE_KEY, [])) || [], null, STORY_NODES); + relayout(); + defineScenes(); + sceneFsm.start(SCENE_MENU); + inited = true; + ctx.log('init', 'story 就绪 menu', { vw: VW, vh: VH, endings: endingIds.length, unlocked: unlockedEndings.length }); + }, + + update(dt) { + if (!inited) return; + measures.frames += 1; + if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★ 插件 onFrame 推进 + sceneFsm.update(dt); + elapsedMs += dt * 1000; // dt 秒 → 毫秒,所有相位的统一时钟 + }, + + render(g) { + if (!inited) return; + sceneFsm.render(g); + if (juice) { try { juice.render(g); } catch (_) { /* 容错 */ } } + }, + + handleTap, + + destroy() { + try { sceneFsm.reset(); } catch (_) { /* 幂等 */ } + inited = false; + }, + + /** + * 取证视图【自动验收契约】: + * · state() 内**实时**算(绝不外层先算好闭包返回) + * · targets:play 态 = 当前每个选项一项(中心坐标,occupied:true 可点)——自动验收据此驱动剧情 + * · score := 已做选择数,随推进单调上升——自动验收据此判「真有进展」 + * · menu/over 态 targets = 空数组(键保留、值为空 → driver 不误点) + */ + _forensicsView() { + return { + state: () => { + const cur = sceneFsm.current(); + const n = getNode(nodeId); + const inPlay = cur === SCENE_PLAY && n && !isEnding(n); + return { + phase: cur, + score: sessionScore.getScore(), + best: sessionScore.getBest(), + outcome: sessionScore.getOutcome(), + node: nodeId, + stats: { ...stats }, + endingsUnlocked: unlockedEndings.length, + endingsTotal: endingIds.length, + targets: inPlay + ? choiceRects.map((r) => ({ + x: r.x + r.w / 2, y: r.y + r.h / 2, choice: r.choice, occupied: true, + label: (n.choices[r.choice] && n.choices[r.choice].label) || '', + })) + : [], + }; + }, + measures: () => ({ ...measures }), + }; + }, + + /** 直接命令入口(host.do(action);e2e 辅助:绕坐标确定性触发)。 */ + _handleAction(action) { + if (!action || !inited) return; + const cur = sceneFsm.current(); + if (action.type === 'start') { enterPlay(); return; } + if (action.type === 'choose' && cur === SCENE_PLAY) { + const idx = (action.payload && action.payload.choice) | 0; + if (idx >= 0) choose(idx); + return; + } + if (action.type === 'restart' && cur === SCENE_OVER) { enterPlay(); return; } + }, + }; + + return instance; +} diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game.js b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/game.js @@ -0,0 +1,73 @@ +/** + * game.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】装载契约 GameHostFactory 薄 wrapper。 + * owner:_template L1 固定骨架 | 消费方:host-config.js(templateFactory)。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【职责 · 为何固定】 + * 把 host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 的 game-logic.js。 + * —— 把"易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**, + * 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` → 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类。 + * 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool 拒)。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。 +import { createGame as createGameLogic } from './game-logic.js'; + +/** + * 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。 + * ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport}); + * ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入, + * pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。 + * —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入** + * (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。 + * 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。 + * @param {Object} [opts] 工厂选项(host wiring)。 + * @returns {import('../../../src/core/game-host.d.ts').GameInstance} + */ +export default function gameFactory(opts) { + const runtime = (opts && opts.runtime) || {}; + // L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。 + const game = createGameLogic({ + plugins: runtime.plugins || {}, + bundle: runtime.bundle, + viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844 + }); + + /* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */ + const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null; + const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null; + /** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */ + let inputSubs = []; + + game.init = async function init(boot) { + if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu) + const ctx = boot && boot.ctx; + const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null; + if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩) + // pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。 + if (typeof game.handleTap === 'function') { + inputSubs.push(input.on('pointerdown', (e) => { + try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧 + })); + } + // keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。 + if (typeof game.handleKey === 'function') { + inputSubs.push(input.on('keydown', (e) => { + try { game.handleKey((e && e.key) || ''); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); } + })); + } + }; + + game.destroy = function destroy() { + for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } } + inputSubs = []; + if (innerDestroy) innerDestroy(); + }; + + return game; +} diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/host-config.js b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/host-config.js @@ -0,0 +1,110 @@ +/** + * host-config.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring。 + * owner:_template L1 固定骨架 | 消费方:main.js(spike)/ entry-bundle.js(SAA)经 buildTemplateHostConfig 装配 bootGameHost。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【本文件做什么 · 为何固定】 + * 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring。 + * 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源。 + * 3 层架构(2026-06-22 边界设计)下,host-config 是 **L1 工程骨架**:LLM 不碰(writeFileTool 拒写), + * 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)。 + * + * 【全标准能力插件集(D2 全注入,创始人裁 D9=A)】 + * 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost + * · 音频:audioMusic · 物理:collision/physics —— 共 11 件全摆上桌,L3 按需取用(用到才解构)、不用的不解构即可。 + * 默认 config 与 generic-host-config 同口径(能力库默认面,非玩法);均 node-import-safe(碰受控资源只在 init/runtime 期)。 + * + * 【Q4 铁律】本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost 经 opts.engine 注入。node import-safe。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。 +import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js'; +import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js'; +import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js'; +import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js'; +// 持久(最佳分等):优先 localStorage,不可用退内存。 +import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js'; +// 手感/特效/后处理(打击感 / 粒子 / 暗角)。 +import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js'; +import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js'; +import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js'; +// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。 +import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js'; +// 物理/碰撞(网格碰撞 + 轻物理)。 +import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js'; +import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js'; + +// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。 +import createGame from './game.js'; + +/** 本款游戏工厂(GameHostFactory)。 */ +export const templateFactory = createGame; + +/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */ +export const templateViewport = { w: 390, h: 844 }; + +/** + * 装配调 bootGameHost 所需的全部入参(全 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)。 + * main.js / entry-bundle.js 补 canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost。 + * @param {{seed:number}} args + * @returns {{factory:Function, plugins:object, registerOrder:Array, viewport:{w:number,h:number}, buildFactoryOpts:Function}} + */ +export function buildTemplateHostConfig(args) { + const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0; + + /* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */ + let saveAdapter; + try { + saveAdapter = createLocalStorageAdapter(); + } catch (e) { + console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message)); + saveAdapter = createMemoryAdapter(); + } + const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter }); + + /* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */ + const sceneFsm = createSceneFsmPlugin(); + // session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。 + const sessionScore = createSessionScorePlugin({ + best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) }, + }); + const hudUi = createHudUiPlugin(); + const timerScheduler = createTimerSchedulerPlugin(); + + /* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */ + const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] }); + const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 }); + const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } }); + const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 }); + const collision = createCollisionPlugin({ cellSize: 96 }); + const physics = createPhysicsLitePlugin(); + + // 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。 + const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics }; + // 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。 + const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic]; + + return { + factory: templateFactory, + plugins, + registerOrder, + viewport: templateViewport, + /** + * 构造工厂 opts(host wiring):经 runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂。 + * game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport 交 L3 的 createGame。 + * @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)。 + * @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄。 + */ + buildFactoryOpts: (_bootCtx, hostInternals) => ({ + runtime: { + plugins: hostInternals.plugins, + bundle: hostInternals.bundle, + getRealAudioCtx: hostInternals.getRealAudioCtx, + viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码 + }, + }), + }; +} diff --git a/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/render.js b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/render.js new file mode 100644 index 00000000..31b2c854 --- /dev/null +++ b/cheap-worker/fixtures/golden-samples/narrative/p11a-s1/src/render.js @@ -0,0 +1,349 @@ +/** + * render.js — 【L3 表现层】剧情互动品类 ·「深渊回声」的深海绘制 + * 一律画在入参 g(= boot.mainContext)。本档全程序化绘制(零外部美术)。 + * + * 观感(深海悬疑): + * · 背景:深海军蓝 → 漆黑 纵渐层(1800 米之下),水波扭曲 + * · 顶栏:深度计(随节点深度变化,有刻度 + 当前米数 + 生命信号点) + * · 底栏:被动声呐(脉冲环 + 三个生命点闪烁) + * · 文本卡:船舱金属底 + 圆角 + 暖琥珀色边(深海人造物唯一暖光) + * · 属性 HUD:冷静 / 胆识(发蓝绿光,像仪表) + * · 浮游生物:在背景上缓缓漂的浅蓝小点(随时间相位漂移) + * · 节点「lower_lab」时屏底有大型黑色剪影(暗示威胁) + * · 节点「crystal_truth」时粒子从屏底上升(暗示接触) + */ + +'use strict'; + +/* ── 配色(深海)── */ +const C = { + // 背景渐层 + bgTop: '#0a1726', // 浅水层深海军蓝 + bgMid: '#0c1a30', // 中层 + bgBottom: '#04080f', // 深渊(几乎全黑) + // 文本 + title: '#e8f4ff', // 亮白 + dim: '#6a86a8', // 仪表暗 + amber: '#f0b860', // 暖琥珀(舱内灯) + amberDim: '#8a6a30', + amberText: '#1a0f04', // 琥珀卡上的正文(深) + // 仪表 + sonar: '#3df0c8', // 声呐青绿 + sonarDim: '#1a6a5a', + signal: '#7ed957', // 生命信号绿 + signalBad: '#e85a4f', // 异常红 + // 结局配色 + win: '#7ed957', + winGlow: '#3df0c8', + lose: '#e85a4f', + loseGlow: '#7a1a14', + // 浮游生物 / 装饰 + plankton: 'rgba(140,210,240,0.55)', + planktonDim: 'rgba(140,210,240,0.18)', +}; + +/* ────────── 公共绘制零件(观感层)────────── */ + +let _seedRand = 0x9E3779B1; // 浮游生物位置 = 受控伪随机 +function seedReset() { _seedRand = 0x9E3779B1; } +function r01() { _seedRand = (_seedRand * 1664525 + 1013904223) >>> 0; return _seedRand / 0xFFFFFFFF; } +function planktonPoints(vw, vh, count) { + // 稳定的"星位":同一 vw/vh 给同一组位置 + seedReset(); + const out = []; + for (let i = 0; i < count; i++) { + out.push({ x: r01() * vw, y: r01() * vh, s: 0.6 + r01() * 1.4, ph: r01() * Math.PI * 2 }); + } + return out; +} + +/** + * 中文友好逐字换行(canvas 无自动换行;中文无空格分词,按字宽累积断行)。 + */ +export function wrapText(g, text, x, y, maxW, lineH, maxLines) { + const limit = maxLines || 8; + let line = ''; + let drawn = 0; + for (const ch of String(text || '')) { + const probe = line + ch; + if (g.measureText(probe).width > maxW && line) { + g.fillText(line, x, y + drawn * lineH); + drawn += 1; + if (drawn >= limit) return drawn; + line = ch; + } else { + line = probe; + } + } + if (line && drawn < limit) { + g.fillText(line, x, y + drawn * lineH); + drawn += 1; + } + return drawn; +} + +/* ── 深海背景:三段渐层 + 浮游生物 + (可选)剪影 ── */ +function drawAbyss(g, vw, vh, t, opts) { + // 三段渐层 + const grd1 = g.createLinearGradient(0, 0, 0, vh * 0.55); + grd1.addColorStop(0, C.bgTop); grd1.addColorStop(1, C.bgMid); + g.fillStyle = grd1; g.fillRect(0, 0, vw, vh * 0.55); + const grd2 = g.createLinearGradient(0, vh * 0.55, 0, vh); + grd2.addColorStop(0, C.bgMid); grd2.addColorStop(1, C.bgBottom); + g.fillStyle = grd2; g.fillRect(0, vh * 0.55, vw, vh); + + // 浮游生物(稳定位置 + 时间相位,模拟缓慢漂浮) + const pts = planktonPoints(vw, vh, 36); + for (const p of pts) { + const dx = Math.sin(t * 0.00015 + p.ph) * 6; + const dy = Math.cos(t * 0.00018 + p.ph * 1.3) * 4; + g.fillStyle = C.plankton; + g.beginPath(); g.arc(p.x + dx, p.y + dy, p.s, 0, Math.PI * 2); g.fill(); + } + // 远景生物(更暗更稀疏) + for (const p of planktonPoints(vw, vh, 14)) { + g.fillStyle = C.planktonDim; + g.beginPath(); g.arc(p.x, p.y, 1.2, 0, Math.PI * 2); g.fill(); + } + + // 可选:站体剪影(右下角) + if (opts && opts.silhouette) drawStationSilhouette(g, vw, vh, opts.silhouette); + // 可选:大型生物剪影(下层实验室节点) + if (opts && opts.creature) drawCreatureSilhouette(g, vw, vh, t, opts.creature); +} + +/* 站体剪影:几段矩形 + 圆顶(深海科考站想象图) */ +function drawStationSilhouette(g, vw, vh, kind) { + g.save(); + g.fillStyle = '#02060d'; + // 站体主体(右下方) + const x0 = vw * 0.62, y0 = vh * 0.72; + g.fillRect(x0, y0, vw * 0.22, vh * 0.12); // 主体 + g.fillRect(x0 + vw * 0.04, y0 - vh * 0.05, vw * 0.14, vh * 0.05); // 中层 + // 圆顶观察窗 + g.beginPath(); g.arc(x0 + vw * 0.08, y0 - vh * 0.05, 6, 0, Math.PI * 2); g.fill(); + // 亮窗(琥珀色,站里还亮着) + g.fillStyle = C.amber; + g.fillRect(x0 + vw * 0.14, y0 + vh * 0.02, 8, 10); + g.fillStyle = C.signal; + g.fillRect(x0 + vw * 0.02, y0 + vh * 0.04, 6, 6); + g.restore(); +} + +/* 大型生物剪影(lower_lab 节点):屏底右上一个模糊的黑色波浪 */ +function drawCreatureSilhouette(g, vw, vh, t, kind) { + g.save(); + g.fillStyle = 'rgba(0,0,0,0.85)'; + // 用多条波带叠加,形成模糊身形 + const yBase = vh * 0.86; + const drift = Math.sin(t * 0.0003) * 4; + for (let i = 0; i < 4; i++) { + g.beginPath(); + const yy = yBase - i * 14 + drift; + g.moveTo(vw * 0.30, vh); + for (let x = 0; x <= vw; x += 12) { + const y = yy + Math.sin(x * 0.018 + t * 0.0006 + i * 0.7) * 4; + g.lineTo(x, y); + } + g.lineTo(vw, vh); g.closePath(); + g.fill(); + } + // 两点冷光(眼睛) + g.fillStyle = kind === 'truth' ? C.winGlow : C.signalBad; + g.beginPath(); g.arc(vw * 0.66, vh * 0.84, 3, 0, Math.PI * 2); g.fill(); + g.beginPath(); g.arc(vw * 0.72, vh * 0.84, 3, 0, Math.PI * 2); g.fill(); + g.restore(); +} + +/* 文本卡:船舱金属底(深蓝灰)+ 琥珀描边,这是深海里唯一暖色 */ +function drawCard(g, x, y, w, h) { + g.fillStyle = 'rgba(16,28,46,0.92)'; + g.beginPath(); + if (g.roundRect) g.roundRect(x, y, w, h, 14); else g.rect(x, y, w, h); + g.fill(); + g.strokeStyle = C.amber; g.lineWidth = 1.5; + g.beginPath(); + if (g.roundRect) g.roundRect(x, y, w, h, 14); else g.rect(x, y, w, h); + g.stroke(); + // 角光(四个琥珀小角) + g.fillStyle = C.amber; + const cs = 6; + g.fillRect(x - 1, y - 1, cs, 2); + g.fillRect(x - 1, y - 1, 2, cs); + g.fillRect(x + w - cs + 1, y - 1, cs, 2); + g.fillRect(x + w - 1, y - 1, 2, cs); + g.fillRect(x - 1, y + h - 1, cs, 2); + g.fillRect(x - 1, y + h - cs + 1, 2, cs); + g.fillRect(x + w - cs + 1, y + h - 1, cs, 2); + g.fillRect(x + w - 1, y + h - cs + 1, 2, cs); +} + +/* 顶栏:深度计 + 生命信号 */ +function drawDepthGauge(g, hud, vw, depth, signals, vh) { + // 深度数字(左上) + hud.drawText(g, '深度 ' + depth + 'M', 14, 26, + { font: '700 13px sans-serif', color: C.sonar, align: 'left', baseline: 'middle' }); + // 生命信号(右上,3 个圆点) + const dotsX = vw - 14; + for (let i = 0; i < 3; i++) { + const ok = signals > i; + g.fillStyle = ok ? C.signal : C.signalBad; + g.beginPath(); g.arc(dotsX - i * 16, 26, 4, 0, Math.PI * 2); g.fill(); + if (ok) { + g.strokeStyle = C.signal; g.lineWidth = 1; g.beginPath(); + g.arc(dotsX - i * 16, 26, 6, 0, Math.PI * 2); g.stroke(); + } + } + hud.drawText(g, '生命信号', dotsX - 32, 26, + { font: '600 11px sans-serif', color: C.dim, align: 'right', baseline: 'middle' }); +} + +/* 底栏:被动声呐(脉冲环 + 文字) */ +function drawSonar(g, hud, vw, vh, t, pulse) { + const cx = vw / 2, cy = vh * 0.93; + // 三层脉冲(相位错开) + for (let i = 0; i < 3; i++) { + const ph = (t * 0.0008 + i * 0.33) % 1; + const r = 8 + ph * 60; + const a = (1 - ph) * 0.35; + g.strokeStyle = 'rgba(61,240,200,' + a.toFixed(3) + ')'; + g.lineWidth = 1.5; + g.beginPath(); g.arc(cx, cy, r, 0, Math.PI * 2); g.stroke(); + } + hud.drawText(g, '声呐 PING ' + (pulse > 0.7 ? '●' : pulse > 0.4 ? '◐' : '○'), + cx, cy + 22, + { font: '600 11px sans-serif', color: C.sonar, align: 'center', baseline: 'middle' }); +} + +/* 属性 HUD(顶部一行,在深度计下方) */ +function drawStats(g, hud, statNames, stats, vw, y) { + const keys = Object.keys(statNames || {}); + if (!keys.length) return; + const cellW = Math.min(150, (vw - 24) / keys.length); + keys.forEach((k, i) => { + const cx = 12 + i * cellW; + const v = stats[k] || 0; + hud.drawText(g, statNames[k] + ' ' + v, cx, y, + { font: '600 14px sans-serif', color: C.sonar, align: 'left', baseline: 'middle' }); + // 小条 + const barX = cx, barY = y + 12, barW = 56, barH = 4; + g.fillStyle = C.sonarDim; g.fillRect(barX, barY, barW, barH); + g.fillStyle = C.sonar; g.fillRect(barX, barY, Math.min(1, v / 5) * barW, barH); + }); +} + +/* ────────── 场景绘制入口(导出给 game-logic 用)────────── */ + +/** + * menu:标题 + 引子卡 + 开始按钮 + 站体剪影。 + */ +export function renderMenu(g, hud, view) { + const { vw, vh, title, subtitle, t = 0 } = view; + drawAbyss(g, vw, vh, t, { silhouette: true }); + // 标题 + hud.drawText(g, title, vw / 2, vh * 0.20, + { font: '800 48px serif', color: C.title, align: 'center', baseline: 'middle' }); + hud.drawText(g, '1800 米之下,只剩你一个', vw / 2, vh * 0.27, + { font: '600 13px sans-serif', color: C.dim, align: 'center', baseline: 'middle' }); + // 引子卡 + const cardX = vw * 0.08, cardW = vw * 0.84; + drawCard(g, cardX, vh * 0.34, cardW, 110); + g.fillStyle = C.amber; g.font = '14px sans-serif'; g.textAlign = 'left'; g.textBaseline = 'top'; + wrapText(g, subtitle, cardX + 18, vh * 0.34 + 16, cardW - 36, 24, 4); + // 副提示 + hud.drawText(g, '每次选择,冷静与胆识会累积', vw / 2, vh * 0.52, + { font: '13px sans-serif', color: C.dim, align: 'center', baseline: 'middle' }); + // 开始按钮 + const w = 240, h = 60; + const startBtn = hud.drawButton(g, { x: vw / 2 - w / 2, y: vh * 0.58 - h / 2, w, h }, + { label: '下 潜', font: '700 22px sans-serif', fill: C.amber, textColor: C.amberText, radius: 30 }); + return { startBtn }; +} + +/** + * play:深海底 + 深度计 + 属性 HUD + 场景文本卡 + 选项按钮 + 声呐底栏。 + * view: { vw, vh, node, rects, stats, statNames, stepCount, pulse, depth, signals, t, hasSample } + */ +export function renderPlay(g, hud, view) { + const { vw, vh, node, rects, stats, statNames, stepCount, pulse, depth = 1800, signals = 3, t = 0, hasSample = false } = view; + // 背景:依据节点决定是否画站体/生物剪影 + const isLower = node && (node.id === 'lower_lab' || node.id === 'crystal_truth'); + const isTruth = node && node.id === 'crystal_truth'; + drawAbyss(g, vw, vh, t, { + silhouette: !isLower, + creature: isLower ? (isTruth ? 'truth' : 'lower') : null, + }); + // 顶栏 + drawDepthGauge(g, hud, vw, depth, signals, vh); + drawStats(g, hud, statNames, stats, vw, 50); + hud.drawText(g, '第 ' + (stepCount + 1) + ' 章', vw - 14, 50, + { font: '600 13px sans-serif', color: C.amber, align: 'right', baseline: 'middle' }); + // 场景文本卡 + const cardX = vw * 0.06, cardW = vw * 0.88, cardY = vh * 0.30, cardH = vh * 0.24; + drawCard(g, cardX, cardY, cardW, cardH); + g.fillStyle = C.amber; g.font = '16px sans-serif'; g.textAlign = 'left'; g.textBaseline = 'top'; + wrapText(g, node && node.text, cardX + 18, cardY + 18, cardW - 36, 26, 5); + // 样本徽章(右上角:你带着水晶) + if (hasSample) { + hud.drawText(g, '◆ 水晶样本', cardX + cardW - 16, cardY + 12, + { font: '700 12px sans-serif', color: C.sonar, align: 'right', baseline: 'middle' }); + } + // 呼吸提示 + const alpha = 0.30 + 0.55 * (pulse == null ? 1 : pulse); + g.save(); g.globalAlpha = alpha; + hud.drawText(g, '▼ 你的决定', vw / 2, vh * 0.575, + { font: '600 13px sans-serif', color: C.dim, align: 'center', baseline: 'middle' }); + g.restore(); + // 选项按钮(照 core.layoutChoices 的 rects 画) + const choices = (node && node.choices) || []; + for (const r of rects || []) { + const c = choices[r.choice]; + if (!c) continue; + hud.drawButton(g, { x: r.x, y: r.y, w: r.w, h: r.h }, + { label: c.label, font: '600 18px sans-serif', fill: 'rgba(61,240,200,0.10)', + textColor: C.title, stroke: C.sonar, lineWidth: 1.5, radius: 14 }); + } + // 底栏声呐 + drawSonar(g, hud, vw, vh, t, pulse == null ? 1 : pulse); +} + +/** + * over:结局称号 + 结局文本卡 + 属性回顾 + 结局图鉴 + 再读一次按钮。 + */ +export function renderOver(g, hud, view) { + const { vw, vh, endingNode, stats, statNames, unlocked, endingIds, nodes, t = 0 } = view; + // 结局配色 + const isWin = endingNode && endingNode.outcome === 'win'; + drawAbyss(g, vw, vh, t, { silhouette: true }); + // 暗罩 + g.fillStyle = isWin ? 'rgba(8,28,38,0.66)' : 'rgba(20,6,8,0.72)'; + g.fillRect(0, 0, vw, vh); + // 结局类型 + hud.drawText(g, isWin ? '回 声 抵 达' : '通 讯 中 断', vw / 2, vh * 0.12, + { font: '600 16px sans-serif', color: isWin ? C.win : C.lose, align: 'center', baseline: 'middle' }); + // 称号 + hud.drawText(g, '「' + ((endingNode && endingNode.title) || '未知') + '」', vw / 2, vh * 0.19, + { font: '800 34px serif', color: isWin ? C.winGlow : C.lose, align: 'center', baseline: 'middle' }); + // 结局文本卡 + const cardX = vw * 0.08, cardW = vw * 0.84; + drawCard(g, cardX, vh * 0.25, cardW, 130); + g.fillStyle = C.amber; g.font = '14px sans-serif'; g.textAlign = 'left'; g.textBaseline = 'top'; + wrapText(g, endingNode && endingNode.text, cardX + 16, vh * 0.25 + 16, cardW - 32, 24, 5); + // 属性回顾 + const keys = Object.keys(statNames || {}); + const statLine = keys.map((k) => statNames[k] + ' ' + (stats[k] || 0)).join(' '); + hud.drawText(g, statLine, vw / 2, vh * 0.45, + { font: '600 15px sans-serif', color: C.sonar, align: 'center', baseline: 'middle' }); + // 结局图鉴 + const got = new Set(unlocked || []); + hud.drawText(g, '结局图鉴 ' + got.size + ' / ' + endingIds.length, vw / 2, vh * 0.52, + { font: '700 15px sans-serif', color: C.amber, align: 'center', baseline: 'middle' }); + const line = endingIds.map((id) => (got.has(id) ? '「' + nodes[id].title + '」' : '???')).join(' '); + hud.drawText(g, line, vw / 2, vh * 0.52 + 26, + { font: '12px sans-serif', color: C.dim, align: 'center', baseline: 'middle' }); + // 再读一次 + const w = 240, h = 60; + const restartBtn = hud.drawButton(g, { x: vw / 2 - w / 2, y: vh * 0.66 - h / 2, w, h }, + { label: '再 潜 一 次', font: '700 20px sans-serif', fill: C.amber, textColor: C.amberText, radius: 30 }); + return { restartBtn }; +} diff --git a/contracts/gate-fixtures/README.md b/contracts/gate-fixtures/README.md index 032f23b2..02d70a2f 100644 --- a/contracts/gate-fixtures/README.md +++ b/contracts/gate-fixtures/README.md @@ -24,6 +24,7 @@ - `manifest.json` —— 判例清单(机器可读):每条判例登记它约束哪道门、判例真身在哪(carrier)、判例形态、期望裁决、以及来源(哪次误杀/漏放、哪个提交语境)。判例真身随判据就近而居,清单里只有指针,不重抄。 - `cases/latch-terminal-synonyms.test.mjs` —— 本库唯一新增的判例真身,收编 latch 终态同义词误杀(见下)。它无处可归(`play.cdp.cjs` 是 CLI、无导出、无既有测试位),故安家于此。 +- `products/` —— 产物型判例的复现产物(见下「产物型判例」)。目前收编卡 menu 族 `amgen-fx4c-{1,3,4}`:每份固化 src 源码(卡 menu 证据)+ evidence 摘要,构建产物与生成 trace 不入仓。 - `run.mjs` —— 全量回归统一入口,零新依赖(只用 Node 内置 `node:test` / `node:child_process`)。 ## 判例形态 @@ -59,11 +60,16 @@ node --test contracts/gate-fixtures/cases/latch-terminal-synonyms.test.mjs # la 三处挂点具体接线(改 `.githooks/` / `.gitea/workflows/` / wave-close 脚本)是独立动作,按红线留给挂载单执行。 +## 产物型判例(products/) + +有些误杀/漏放已有复现产物、也钉死了归因,但真跑要九门 CDP 浏览器环境(driver 起局 + latch 驻留取证),没法塞进 `run.mjs` 这趟零依赖回归。这类判例落 `products/`:固化判例核心证据(src 源码 + evidence 摘要),在 `manifest.json` 的 `product_fixtures` 登记期望裁决与重建命令,作文档基线;等 harness 托管化能离线回放,再升为端到端 stage。它们既不是 `cases`(可运行、被 stage 驱动),也不是 `pending`(只有叙述、无载体),是中间的第三态。 + +- **卡 menu 族 `amgen-fx4c-{1,3,4}`**(已固化,复现窗 2026-07-03):三款游戏都真玩起不了局、卡在 menu。归因已钉两类——案 1/3(接水果、太空躲避)是驱动器约定漂移:起局靠点击非方向键的 `menuStartBtn`,而 `runKeyCycle` 驱动器无起局逻辑就点不到、把合法游戏误判卡 menu(误杀防线);案 4(点击命中类)是并发 flaky:`menuStartBtn` 仅在 `render()` 副作用里赋值,rAF 饥饿下恒 null(脆弱写法防线)。每份只固化 `src/`(卡 menu 源码证据)与 `evidence/`(成本/步数摘要);bundle、index.html 等构建产物与约 1MB 的生成 trace(verdict 全 null)不入仓,重建命令见 `manifest.json`。裁决细化归卡 menu 归因单。 + ## 待补产物 有些误杀/漏放目前只有叙述、没有可复跑载体,如实记在 `manifest.json` 的 `pending`,不硬造: -- **卡 menu 族 `amgen-fx4c-{1,3,4}`**:复现产物未入仓(在 mini-desktop/临时),归因已钉(驱动器约定漂移 + 并发 flaky)。把三份复现产物固化入仓、标注期望裁决后即可升为判例。 - **base5 五款完整 CDP 真玩复跑**:完整端到端(含 latch 驻留)需浏览器 + 五款持久化取证快照,快照未入仓;当前以 latch 谓词行为回归覆盖其核心,端到端复跑待补。 - **wg1 verdict 基线**:`wg1/gen-worker/results/*.json` 约 28 份是产物级金标,但重现需跑完整 gen+play,无零依赖复跑载体,登记为参照基线待 harness 托管化。 diff --git a/contracts/gate-fixtures/manifest.json b/contracts/gate-fixtures/manifest.json index 607a3c61..e3bfe0ef 100644 --- a/contracts/gate-fixtures/manifest.json +++ b/contracts/gate-fixtures/manifest.json @@ -101,13 +101,43 @@ "source": "既有 Python 门单测与 W-GENRE 四品类金标 play-spec/rubric,已就近而居;本库经 run.mjs 在 pytest 可用时统一驱动、登记为门金标的一部分。" } ], - "pending": [ + "product_fixtures_note": "产物型判例:有复现产物载体 + 期望裁决,介于 cases(可运行、被 run.mjs stage 驱动)与 pending(仅叙述、无载体)之间。真跑需九门 CDP 浏览器环境(driver 起局 + latch 驻留取证),故不进 run.mjs 零依赖 stage;完整端到端复跑待 harness 托管化(同 pending.base5-full-cdp-replay)。载体只固化判例核心证据(src 源码 + evidence 摘要);构建产物(bundle/entry/index.html)与生成过程 trace(verdict 全 null、约 1MB)不入仓,附重建命令。", + "product_fixtures": [ { - "id": "amgen-fx4c-menu-1-3-4", + "id": "amgen-fx4c-1", "gate": "latch/driver + 首局门(卡 menu 族)", - "reason": "『卡 menu 族』复现产物 amgen-fx4c-{1,3,4} 未入仓(仅 docs/mvp/MVP作战清单.md 叙述,产物在 mini-desktop/临时)。归因已钉:案1/3=驱动器约定漂移(runKeyCycle 无起局逻辑,凡起局需非方向键的游戏必卡)、案4=并发 flaky(menuStartBtn 仅 render 副作用赋值,rAF 饥饿下恒 null)。", - "to_promote": "把三份复现产物(src 源工程 + 取证 state + play-spec)固化入仓,并给出各自期望裁决(该拦/该放+哪道门),即可加进 run.mjs 的 node stage。" + "kind": "产物型判例(需九门 CDP 环境,不进 run.mjs 零依赖 stage)", + "brief": "接水果——篮子接下落物、三条命、难度递增(自「点圆得分」模板改造)", + "carrier": "contracts/gate-fixtures/products/amgen-fx4c-1/", + "contents": "src/(6 文件:assets/core/game-logic/game/host-config/render.js,卡 menu 源码证据)+ evidence/service-run-summary.json(costRmb 5.1068 / repairs 2 / budgetSoftTripped false / trace 摘要 2765 步)。未入仓:bundle.iife.js·bundle.iife.js.meta.json·entry-bundle.js·index.html(构建/宿主产物)、trace.jsonl(生成过程流式事件、verdict 全 null、约 884KB、无九门裁决)、README.md 与 assets/(_template 克隆残留与空资产 stub)。", + "expect": "该放(游戏起局合法)——起局靠点击 menuStartBtn(非方向键 tap),本身可玩;卡 menu 是误杀。归因(已钉):驱动器约定漂移——runKeyCycle 驱动器无起局逻辑、点不到非方向键起局即把游戏卡在 menu(E_live/H_progress latch/首局门 coreLoop 全判死)。本判例锚定『驱动器非方向键起局约定』:驱动器一旦漂移回无起局逻辑,就对这类合法游戏复现误杀。属误杀防线。裁决细化归卡 menu 归因单(复现窗 2026-07-03)。", + "rebuild": "把 src/ 放回 game-runtime/games/amgen-fx4c-1/ 后 `node game-runtime/tools/amodel-gen/tools.mjs build-saa amgen-fx4c-1`(esbuild 打 SAA 信封 __GameBundle,产 bundle.iife.js/entry-bundle.js/index.html);九门真玩取证需 mini-desktop 浏览器环境。", + "source": "卡 menu 族复现(traceId fix4xx-1,复现窗 2026-07-03)。由 pending.amgen-fx4c-menu-1-3-4 升格:复现产物已固化入仓、期望裁决已标注,故从『待补』翻为『已固化(产物型)』。" }, + { + "id": "amgen-fx4c-3", + "gate": "latch/driver + 首局门(卡 menu 族)", + "kind": "产物型判例(需九门 CDP 环境,不进 run.mjs 零依赖 stage)", + "brief": "太空躲避——飞船闪避坠物(自「点圆得分」模板改造)", + "carrier": "contracts/gate-fixtures/products/amgen-fx4c-3/", + "contents": "src/(6 文件,卡 menu 源码证据;_forensicsView 在 menu 态已把 menuStartBtn 中心暴露为 tap-targets)+ evidence/service-run-summary.json(costRmb 11.2632 / repairs 1 / budgetSoftTripped true / trace 摘要 2913 步)。未入仓同 fx4c-1(bundle/entry/index.html 构建产物、trace.jsonl 约 912KB、README/assets stub)。", + "expect": "该放(游戏起局合法)——同 fx4c-1 的驱动器约定漂移案:起局靠点击 menuStartBtn,driver 无非方向键起局逻辑即误判卡 menu。本判例锚定『驱动器非方向键起局约定』,属误杀防线。裁决细化归卡 menu 归因单(复现窗 2026-07-03)。", + "rebuild": "把 src/ 放回 game-runtime/games/amgen-fx4c-3/ 后 `node game-runtime/tools/amodel-gen/tools.mjs build-saa amgen-fx4c-3`;九门真玩取证需 mini-desktop 浏览器环境。", + "source": "卡 menu 族复现(traceId fix4xx-3,复现窗 2026-07-03)。由 pending.amgen-fx4c-menu-1-3-4 升格。" + }, + { + "id": "amgen-fx4c-4", + "gate": "latch/driver + 首局门(卡 menu 族)", + "kind": "产物型判例(需九门 CDP 环境,不进 run.mjs 零依赖 stage)", + "brief": "点击命中类——命中计分、连击、漏 5 只结束(产物 L3 注释未标品类名)", + "carrier": "contracts/gate-fixtures/products/amgen-fx4c-4/", + "contents": "src/(6 文件,卡 menu 源码证据:menuStartBtn 仅在 render() 副作用里赋值)+ evidence/service-run-summary.json(costRmb 13.0113 / repairs 2 / budgetSoftTripped true / trace 摘要 3202 步)。未入仓同 fx4c-1(bundle/entry/index.html 构建产物、trace.jsonl 约 1.0MB、README/assets stub)。", + "expect": "该拦(游戏写法脆弱)——归因(已钉):并发 flaky,menuStartBtn 仅在 render() 副作用里赋值,rAF 饥饿(并发)下 render 未及时执行→menuStartBtn 恒 null→handleTap 点不到起局→卡 menu。本判例锚定『起局句柄不得依赖 render 副作用时序』:放行这种并发下 flaky 的起局写法即漏放。守门=首局门 coreLoop / E_live / H_progress。裁决细化归卡 menu 归因单(复现窗 2026-07-03)。", + "rebuild": "把 src/ 放回 game-runtime/games/amgen-fx4c-4/ 后 `node game-runtime/tools/amodel-gen/tools.mjs build-saa amgen-fx4c-4`;九门真玩取证需 mini-desktop 浏览器环境。", + "source": "卡 menu 族复现(traceId fix4xx-4,复现窗 2026-07-03)。由 pending.amgen-fx4c-menu-1-3-4 升格。" + } + ], + "pending": [ { "id": "base5-full-cdp-replay", "gate": "latch (play.cdp.cjs H门 完整真玩取证)", diff --git a/contracts/gate-fixtures/products/README.md b/contracts/gate-fixtures/products/README.md new file mode 100644 index 00000000..575c37b0 --- /dev/null +++ b/contracts/gate-fixtures/products/README.md @@ -0,0 +1,26 @@ +# products —— 产物型判例的复现产物 + +这里躺着的不是要跑的游戏,是**判例证据**。每个子目录是一款被门证实误杀或漏放过的游戏工程,固化下来当门的永久回归样本。判据要改,先回来对着它们核一遍。 + +收编与裁决登记在上一层 `manifest.json` 的 `product_fixtures`,归因脉络见 `../README.md` 的「产物型判例」段;这里只说清每份留了什么、怎么还原成可玩产物。 + +## 卡 menu 族 `amgen-fx4c-{1,3,4}`(复现窗 2026-07-03) + +三款便宜档生成的游戏,真玩都起不了局、卡在 menu 场景。它们的价值不在能不能玩,而在 `src/game-logic.js` 里那段**起局代码**——那正是卡 menu 的现场:`menuStartBtn` 只在 `render()` 副作用里赋值,起局靠点击它(非方向键 tap)。案 1(接水果)、案 3(太空躲避)卡在驱动器够不到这个非方向键起局,把合法游戏误判卡 menu(误杀防线);案 4(点击命中类)卡在并发下 `render` 没及时跑、`menuStartBtn` 恒 null(脆弱写法防线)。 + +每份只留两样: + +- `src/` —— 六个源文件,卡 menu 的源码证据,判例的本体。 +- `evidence/service-run-summary.json` —— 生成成本、修复轮次、trace 步数摘要(三份的 cost / repairs / budgetSoftTripped 各不同,记着当时的生成画像)。 + +没留的:`bundle.iife.js` 等 esbuild 构建产物、`index.html` 宿主壳、约 1MB 的生成过程 `trace.jsonl`(全是 LLM 流式事件、九门 verdict 字段全 null,对判例无信息量)、以及 `_template` 克隆残留的 `README.md` 和空的 `assets/`。 + +## 还原成可玩产物 + +判例只留了源码。要真玩取证,把某份的 `src/` 放回 `game-runtime/games//`,再打包: + +```bash +node game-runtime/tools/amodel-gen/tools.mjs build-saa amgen-fx4c-1 +``` + +这一步 esbuild 出 `bundle.iife.js` / `entry-bundle.js` / `index.html`。九门真玩取证还需 mini-desktop 的浏览器环境(driver 起局 + latch 驻留),本机零依赖回归跑不了这段——这也是它们落 `products/` 当产物型判例、而没进 `run.mjs` stage 的原因。 diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/evidence/service-run-summary.json b/contracts/gate-fixtures/products/amgen-fx4c-1/evidence/service-run-summary.json new file mode 100644 index 00000000..b5b89992 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/evidence/service-run-summary.json @@ -0,0 +1,12 @@ +{ + "costRmb": 5.1068, + "rmbGate": "active", + "repairs": 2, + "budgetSoftTripped": false, + "traceSummary": { + "traceId": "fix4xx-1", + "steps": 2765, + "dropped": 0, + "middlewareDropped": 0 + } +} \ No newline at end of file diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/src/assets.js b/contracts/gate-fixtures/products/amgen-fx4c-1/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/src/assets.js @@ -0,0 +1,73 @@ +/** + * assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件) + * owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取) + * + * 【本款现状:无外部资产(纯程序化图形)】 + * 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。 + * 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键, + * render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。 + * + * 【容错铁律(发行版红线 §7-5)】 + * 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退), + * 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。 + * node 环境无 Image → 同样安全返回空集(import-safe)。 + * 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。 + */ + +'use strict'; + +/** + * 图像资产键 → 文件名(与 assets/manifest.json 对应)。 + * 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。 + * 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。 + * @type {Record} + */ +export const IMAGE_FILES = {}; + +/** + * 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。 + * @param {string} [baseUrl] 资产根(缺省 './assets/') + * @returns {Promise<{ images: Record, missing: string[], loaded: number, total: number }>} + */ +export function loadAssets(baseUrl) { + const root = baseUrl == null ? './assets/' : baseUrl; + const keys = Object.keys(IMAGE_FILES); + /** @type {Record} */ + const images = {}; + /** @type {string[]} */ + const missing = []; + + /** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */ + function loadOne(key) { + return new Promise((resolve) => { + // 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。 + if (typeof Image === 'undefined') { + images[key] = null; + missing.push(key); + resolve(null); + return; + } + const img = new Image(); + img.onload = () => { + images[key] = img; + resolve(img); + }; + img.onerror = () => { + // 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。 + console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]); + images[key] = null; + missing.push(key); + resolve(null); + }; + img.src = root + IMAGE_FILES[key]; + }); + } + + // 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。 + return Promise.all(keys.map(loadOne)).then(() => ({ + images, + missing, + loaded: keys.length - missing.length, + total: keys.length, + })); +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/src/core.js b/contracts/gate-fixtures/products/amgen-fx4c-1/src/core.js new file mode 100644 index 00000000..6e9aae3e --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/src/core.js @@ -0,0 +1,220 @@ +/** + * core.js — 「接水果」纯逻辑(下落物生成 / 篮子 / 命中 / 难度) + * owner:本款游戏 L3 玩法身份 WHAT | 消费方:game-logic.js(玩法编排)/ test 节点单测 + * + * 【本文件定位 · 红线】 + * 纯逻辑(状态机 + 规则 + 数值),无 I/O、无引擎、无插件、无 Date.now/Math.random/setTimeout。 + * · 随机一律经入参 `rng`(签名 `() => [0,1)`,由游戏从 `boot.ctx.random.next` 接入,确定可复现); + * · 命中是纯几何(篮子 AABB 矩形 vs 物 AABB 矩形),不依赖任何外部状态; + * · 难度成长公式是纯函数,给相同 score/s 算同 speedMul,便于 node 单测断言。 + */ + +'use strict'; + +/* ────────────────────────────────────────────────────────────────────────── + * 玩法常量(本款游戏特有的数值) + * ────────────────────────────────────────────────────────────────────────── */ + +/** 一局起始命数。 */ +export const INITIAL_LIVES = 3; + +/** 篮子尺寸(逻辑像素)——横长方 AABB。 */ +export const BASKET_W = 92; +export const BASKET_H = 26; + +/** 篮子 y 位置(距视口底,留出底边余量)。 */ +export const BASKET_BOTTOM_MARGIN = 60; + +/** 下落物尺寸(水果 / 炸弹均用此 AABB,按边长 36 的方块近似)。 */ +export const ITEM_SIZE = 36; + +/** + * 下落物从顶生成到落出底(自然逃过篮子)所需时间,秒。 + * 调短到 1.7s 让盲驱动 30s 内能刷多颗(初始 speedMul=1 时 844/1.7 ≈ 496 px/s), + * 篮子 y ≈ 758,从顶部生成点 y=-40 落到篮子约 1.6s。 + */ +export const FALL_DURATION_BASE = 1.7; + +/** 两次生成之间的最小间隔毫秒(200ms 一颗起步,开局即密)。 */ +export const SPAWN_INTERVAL_MIN_MS = 200; +/** 两次生成之间的最大间隔毫秒(720ms 一颗起步)。 */ +export const SPAWN_INTERVAL_MAX_MS = 720; + +/** 初始下落速度倍率(难度系数起步 = 1.0)。 */ +export const SPEED_MUL_BASE = 1.0; +/** 难度成长:每得 1 分,下落速度倍率累加多少。 */ +export const SPEED_MUL_PER_SCORE = 0.006; +/** 难度成长:每跨过 500 分,再额外加速倍率。 */ +export const SPEED_MUL_PER_TIER = 0.32; +/** 难度倍率上限(封顶,避免晚后期不可玩)。 */ +export const SPEED_MUL_MAX = 4.0; + +/** 各种水果分值。 */ +export const SCORE_APPLE = 10; +export const SCORE_ORANGE = 15; +export const SCORE_MELON = 25; +/** 接住炸弹扣分(负向 —— 不扣命以外,少量安抚)。 */ +export const SCORE_BOMB_PENALTY = -5; + +/** 炸弹占比(其余全水果)。 */ +export const BOMB_CHANCE = 0.22; + +/** 难度档位(每 500 分一档)。 */ +export const TIER_STEP_SCORE = 500; + +/* ────────────────────────────────────────────────────────────────────────── + * 纯函数:难度成长 / 下落物生成 / 命中 / 命中后处理 + * ────────────────────────────────────────────────────────────────────────── */ + +/** + * 难度倍率 = base + score*perScore + tier*perTier,封顶 max。 + * @param {number} score 当前分数 + * @returns {number} 难度倍率 ≥ 1 + */ +export function speedMulFromScore(score) { + const tier = Math.floor(Math.max(0, score) / TIER_STEP_SCORE); + const raw = SPEED_MUL_BASE + Math.max(0, score) * SPEED_MUL_PER_SCORE + tier * SPEED_MUL_PER_TIER; + return Math.max(SPEED_MUL_BASE, Math.min(SPEED_MUL_MAX, raw)); +} + +/** + * 难度档位(0 起)。每跨过 TIER_STEP_SCORE 升一档。 + * @param {number} score + * @returns {number} + */ +export function tierFromScore(score) { + return Math.floor(Math.max(0, score) / TIER_STEP_SCORE); +} + +/** + * 在视口内顶部随机生成 x 位置(边距 = ITEM_SIZE/2),产出新下落物。 + * kind 决策:rng() < BOMB_CHANCE → bomb;否则按权重抽苹果/橙/西瓜。 + * y 从视口顶端外(-ITEM_SIZE)出发,向下匀速落。 + * **可选第三参 `initialYOffset`** —— 让调用方把第一颗的 y 推下屏外(开局铺多颗时 + * 三颗间隔开,避免全部挤在最顶上)。None/未传 = 默认 -ITEM_SIZE - 4。 + * @param {number} viewW + * @param {() => number} rng + * @param {number} [initialYOffset] 可选:覆盖默认 y 起点(像素,负值=屏外) + * @returns {{kind:'apple'|'orange'|'melon'|'bomb', x:number, y:number, w:number, h:number, rot:number, vy:number, age:number}} + */ +export function spawnFallingItem(viewW, rng, initialYOffset) { + const margin = ITEM_SIZE / 2 + 4; + const x = margin + rng() * Math.max(1, viewW - margin * 2); + const y = (typeof initialYOffset === 'number') ? initialYOffset : (-ITEM_SIZE - 4); + const r = rng(); + let kind; + if (r < BOMB_CHANCE) { + kind = 'bomb'; + } else { + // 60% apple / 30% orange / 10% melon + const r2 = rng(); + if (r2 < 0.6) kind = 'apple'; + else if (r2 < 0.9) kind = 'orange'; + else kind = 'melon'; + } + return { + kind, + x, + y, + w: ITEM_SIZE, + h: ITEM_SIZE, + rot: rng() * Math.PI * 2, // 初始旋转角 + vy: 0, // 由 stepFallingItem 设置 + age: 0, + }; +} + +/** + * 取一随机 spawn 间隔毫秒(在 [SPAWN_INTERVAL_MIN_MS, SPAWN_INTERVAL_MAX_MS])。 + * @param {() => number} rng + * @returns {number} + */ +export function nextSpawnIntervalMs(rng) { + const r = rng(); + return SPAWN_INTERVAL_MIN_MS + r * (SPAWN_INTERVAL_MAX_MS - SPAWN_INTERVAL_MIN_MS); +} + +/** + * 推进一帧下落物的 y / age / rot。dt 单位=秒。 + * 真实下落速度 = viewH / FALL_DURATION_BASE * speedMul(speedMul=1 时整屏 ≈ 1.7s 落尽)。 + * @param {{kind:string,x:number,y:number,w:number,h:number,rot:number,vy:number,age:number}} item + * @param {number} dt 秒 + * @param {number} viewH + * @param {number} speedMul 难度倍率 + */ +export function stepFallingItem(item, dt, viewH, speedMul) { + const baseVy = viewH / FALL_DURATION_BASE; + const vy = baseVy * Math.max(SPEED_MUL_BASE, speedMul); + item.y += vy * dt; + item.age += dt; + item.vy = vy; + // 旋转缓慢 + item.rot += dt * 1.6; +} + +/** + * 篮子 AABB(输入篮子中心 x 即可,按视口宽钳制防越界)。 + * @param {number} basketCx + * @param {number} viewW + * @param {number} viewH + * @returns {{x:number,y:number,w:number,h:number}} + */ +export function basketRect(basketCx, viewW, viewH) { + const w = BASKET_W; + const h = BASKET_H; + const cx = Math.max(w / 2, Math.min(viewW - w / 2, basketCx)); + return { x: cx - w / 2, y: viewH - BASKET_BOTTOM_MARGIN - h, w, h }; +} + +/** + * 是否命中篮子(AABB 重叠)。 + * **注**:运行时游戏改用 hud-ui 的几何判定;本函数 = 同口径的纯实现,供 core 单测。 + * @param {{x:number,y:number,w:number,h:number}} item + * @param {{x:number,y:number,w:number,h:number}} basket + * @returns {boolean} + */ +export function hitsBasket(item, basket) { + if (!item || !basket) return false; + return ( + item.x < basket.x + basket.w && + item.x + item.w > basket.x && + item.y < basket.y + basket.h && + item.y + item.h > basket.y + ); +} + +/** + * 物品是否已出底(落空)。y > viewH + margin 视为逃过篮子。 + * @param {{y:number,h:number}} item + * @param {number} viewH + * @returns {boolean} + */ +export function hasFallenOffscreen(item, viewH) { + return item.y > viewH + item.h; +} + +/** + * 命中后的效果(拿到 kind → 分数增量 + 是否扣命)。 + * · 水果 → 正分; + * · 炸弹 → 扣分 + 扣 1 命。 + * @param {'apple'|'orange'|'melon'|'bomb'} kind + * @returns {{scoreDelta:number, lifeDelta:number}} + */ +export function resolveCatch(kind) { + switch (kind) { + case 'apple': return { scoreDelta: SCORE_APPLE, lifeDelta: 0 }; + case 'orange': return { scoreDelta: SCORE_ORANGE, lifeDelta: 0 }; + case 'melon': return { scoreDelta: SCORE_MELON, lifeDelta: 0 }; + case 'bomb': return { scoreDelta: SCORE_BOMB_PENALTY, lifeDelta: -1 }; + default: return { scoreDelta: 0, lifeDelta: 0 }; + } +} + +/** + * 物品落空(出底未接) → 视为漏接,水果漏接扣 1 命,炸弹漏接不扣(炸弹落空是好事)。 + * @param {'apple'|'orange'|'melon'|'bomb'} kind + * @returns {number} lifeDelta + */ +export function resolveMiss(kind) { + return kind === 'bomb' ? 0 : -1; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/src/game-logic.js b/contracts/gate-fixtures/products/amgen-fx4c-1/src/game-logic.js new file mode 100644 index 00000000..713e5b3f --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/src/game-logic.js @@ -0,0 +1,556 @@ +/** + * game-logic.js — 【L3 游戏本体】接水果(Fruit Catcher)· 篮子接下落物 · 三条命 · 难度递增。 + * owner:生成 agent(从「点圆得分」模板改造)| 消费方:game.js wrapper(L1)调本文件的 createGame。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【玩法设计 · 轻量】 + * 核心循环:玩家点屏 → 篮子水平滑向点击 x 位置 → 接到下落物 → + * · 水果 +10/15/25 分(基础保证) + * · 炸弹 -5 分 + 扣 1 命(摇杆决策) + * 难度递增:score 越高 → speedMul 越高 → 下落越快。 + * 三条命用完 → game over → 重开循环(可被盲驱动器反复演练 progression)。 + * + * **盲驱动器友好(解决 H_progress / G_input / E_live)** + * 1. 初始下落时间压缩到 ≈ 1.7s/屏,30s 内就能刷 ≥ 30 颗下落到篮子; + * 2. spawn 间隔短(200-720ms 起步),开局预生成 3 颗分散铺满; + * 3. handleTap 命中"tap 点附近的 item"即结算 → 真玩点屏真加/扣分; + * 4. _handleAction.catch(盲驱动主路径)——直接命中"离篮子最近"那枚,**立刻结算**(不等物理), + * 让一次 catch 就推动 score/lives 立即变(盲驱动单步见效); + * 5. 三次 catch-bomb 即触发 over → verification 单会话可达终态; + * 6. state() 每次都构造全新数组(各时间点取的值内容不同,避免"去重态数=1")。 + * + * 【入参】createGame({ plugins, bundle, viewport }) - 扁平结构(L1 已摊平) + * 【返回】GameInstance 五法 + handleTap/handleKey + _forensicsView + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +import { + INITIAL_LIVES, + BASKET_W, + spawnFallingItem, + stepFallingItem, + basketRect, + hitsBasket, + hasFallenOffscreen, + resolveCatch, + speedMulFromScore, + tierFromScore, +} from './core.js'; +import { renderMenu, renderPlay, renderOver } from './render.js'; +import { loadAssets } from './assets.js'; + +const FIXED_DT = 1 / 60; + +const SCENE_MENU = 'menu'; +const SCENE_PLAY = 'play'; +const SCENE_OVER = 'over'; + +/* ── 视觉/手感常量(本款特定)── */ +const BASKET_MAX_SPEED = 760; // 像素/秒 +const BASKET_ACCEL = 3600; // 像素/秒² + +/** + * 【L3 游戏工厂】建「接水果」游戏实例。 + * @param {{plugins:object, bundle:object, viewport:{w:number,h:number}}} args L1 已摊平的入参。 + * @returns {import('/root/games-development-ai/game-runtime/src/core/game-host.d.ts').GameInstance} + */ +export function createGame({ plugins, bundle, viewport }) { + const { sceneFsm, sessionScore, hudUi, timerScheduler, juice, audioMusic, save } = plugins; + const VW = (viewport && viewport.w) || 390; + const VH = (viewport && viewport.h) || 844; + + /* ── init(boot) 内填充 ── */ + let boot = null; + let ctx = null; + let assets = null; + let inited = false; + + /* ── 本款运行态(实例私有)── */ + let basketCx = VW / 2; + let basketVx = 0; + let targetVx = 0; + /** 下落物数组。 */ + const items = []; + let spawnCountdownMs = 0; + let lives = INITIAL_LIVES; + let maxLives = INITIAL_LIVES; + let hits = 0; + let bombsCaught = 0; + let maxTier = 0; + const floaterQueue = []; + let hurtFlashMs = 0; + let goodFlashMs = 0; + + /* ── 取证度量 ── */ + const measures = { frames: 0, taps: 0, catches: 0, bombs: 0, missed: 0, phases: { menu: 0, play: 0, over: 0 } }; + + /* ── 命中区 ── */ + let menuStartBtn = null; + let overRestartBtn = null; + + function rng() { return ctx ? ctx.random.next() : 0; } + function currentSpeedMul() { return speedMulFromScore(sessionScore.getScore()); } + + /** 进入 play:重置全部局内状态、生成首批物、起 spawn 计时。 */ + function enterPlay() { + sessionScore.reset(); + lives = INITIAL_LIVES; + maxLives = INITIAL_LIVES; + hits = 0; + bombsCaught = 0; + maxTier = 0; + items.length = 0; + basketCx = VW / 2; + basketVx = 0; + targetVx = 0; + floaterQueue.length = 0; + hurtFlashMs = 0; + goodFlashMs = 0; + + // 盲驱动友好:开局预铺 3 颗,分散 x + 不同 y 起点,让首次 catch 立刻见效 + items.push(spawnFallingItem(VW, rng, -70)); + items.push(spawnFallingItem(VW, rng, -260)); + items.push(spawnFallingItem(VW, rng, -460)); + + // 1.7s 落到底,~200ms 后第一波再次生成 + spawnCountdownMs = 220; + sceneFsm.transition(SCENE_PLAY); + if (ctx) ctx.log('scene', 'play 开始', { lives, vw: VW, vh: VH }); + if (audioMusic) audioMusic.playSfx('click'); + } + + function enterOver() { + if (ctx) ctx.log('scene', 'over 结算', { score: sessionScore.getScore(), best: sessionScore.getBest(), maxTier }); + if (juice) { + juice.shakeScreen(0.4, 6, 28); + juice.flashScreen(0.25, 0.6); + } + if (audioMusic) audioMusic.playSfx('fail'); + try { if (save) save.set('fruitcatcher_best', sessionScore.getBest()); } catch (_) { /* 降级 */ } + sceneFsm.transition(SCENE_OVER); + } + + /** 扣一命 + 短闪 + 屏震。命中即结算。 */ + function onLoseLife(idx, reason) { + lives = Math.max(0, lives - 1); + hurtFlashMs = 180; + if (juice) { + juice.shakeScreen(0.3, 8, 30); + juice.flashScreen(0.12, 0.35); + } + if (audioMusic) audioMusic.playSfx(reason === 'bomb' ? 'hit' : 'fail'); + if (ctx) ctx.log('life', '扣命', { reason, lives }); + const it = items[idx]; + if (it) pushFloater('-1 ♥', it.x, it.y, '#ff5c5c'); + items.splice(idx, 1); + if (lives <= 0) { + enterOver(); + } + } + + /** 接到水果:加分 + 飘字 + 短闪 + 粒子 + 音效。 */ + function onCatchFruit(idx) { + const it = items[idx]; + if (!it) return; + const res = resolveCatch(it.kind); + sessionScore.addScore(res.scoreDelta); + hits += 1; + measures.catches += 1; + pushFloater('+' + res.scoreDelta, it.x, it.y, '#9be36b'); + if (juice) { + const color = it.kind === 'melon' ? '#5ec46b' : (it.kind === 'orange' ? '#ff9a3c' : '#e64545'); + juice.burst(it.x, it.y, { + speedMin: 90, speedMax: 220, + lifeMin: 0.35, lifeMax: 0.7, + count: 14, gravity: 320, drag: 0.4, + sizeCurve: { kind: 'easeInOut', from: 6, to: 1.5 }, + alphaCurve: { kind: 'decay', from: 0.9, to: 0, power: 1.6 }, + angle: Math.PI / 2, spread: Math.PI * 0.9, + }); + } + if (audioMusic) audioMusic.playSfx('coin'); + const newTier = tierFromScore(sessionScore.getScore()); + if (newTier > maxTier) { + maxTier = newTier; + goodFlashMs = 260; + if (juice) juice.pulseScale(0.35, 0.12, 5); + if (ctx) ctx.log('tier', '升级', { tier: newTier }); + } + items.splice(idx, 1); + } + + /** 接到炸弹:扣分 + 扣命 + 强屏震 + 红闪。 */ + function onCatchBomb(idx) { + const it = items[idx]; + if (!it) return; + const res = resolveCatch('bomb'); + sessionScore.addScore(res.scoreDelta); + bombsCaught += 1; + measures.bombs += 1; + if (juice) { + juice.burst(it.x, it.y, { + speedMin: 200, speedMax: 380, + lifeMin: 0.5, lifeMax: 0.9, + count: 22, gravity: 220, drag: 0.2, + sizeCurve: { kind: 'easeInOut', from: 8, to: 0 }, + alphaCurve: { kind: 'decay', from: 1, to: 0, power: 1.4 }, + angle: Math.PI / 2, spread: Math.PI, + }); + } + onLoseLife(idx, 'bomb'); + } + + /** 落空:水果扣命,炸弹落空(被躲开)小奖励。 */ + function onMiss(idx) { + const it = items[idx]; + if (!it) return; + if (it.kind === 'bomb') { + sessionScore.addScore(3); + pushFloater('+3 闪避', it.x, Math.min(VH - 80, it.y), '#9be36b'); + items.splice(idx, 1); + return; + } + measures.missed += 1; + onLoseLife(idx, 'miss'); + } + + function pushFloater(text, x, y, color) { + floaterQueue.push({ text, x, y, lifeMs: 700, ttlMs: 700, color: color || '#fff' }); + } + + function stepFloaters(dt) { + for (let i = floaterQueue.length - 1; i >= 0; i--) { + const f = floaterQueue[i]; + f.lifeMs -= dt * 1000; + f.y -= dt * 36; + if (f.lifeMs <= 0) floaterQueue.splice(i, 1); + } + } + + function drawFloaters(g) { + for (let i = 0; i < floaterQueue.length; i++) { + const f = floaterQueue[i]; + hudUi.drawText(g, f.text, f.x, f.y, { + font: '700 18px sans-serif', + color: f.color, + align: 'center', + baseline: 'middle', + }); + } + } + + function drawOverlays(g) { + if (hurtFlashMs > 0) { + g.save(); + g.fillStyle = 'rgba(255,60,60,0.18)'; + g.fillRect(0, 0, VW, VH); + g.restore(); + } else if (goodFlashMs > 0) { + g.save(); + g.fillStyle = 'rgba(150,255,170,0.10)'; + g.fillRect(0, 0, VW, VH); + g.restore(); + } + } + + /** + * 真玩点击主入口(L1 在 pointerdown 时直达调用,**不延迟、不在 update 消费**)。 + * play:点屏 (x,y) → + * 1) 篮子水平目标速度(左半/右半 → 向左/向右上速度); + * 2) 同时检查"tap 点附近是否有 item"——若有,直接结算该 item(立刻加/扣分), + * 这是真玩 + 盲驱动都友好的策略:玩家点中物品的瞬间 = 接住事件。 + * menu/over:点按钮进游戏/重开。 + */ + function handleTap(x, y) { + if (!inited) return; + measures.taps += 1; + const cur = sceneFsm.current(); + if (cur === SCENE_MENU) { + if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay(); + return; + } + if (cur === SCENE_OVER) { + if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay(); + return; + } + if (cur === SCENE_PLAY) { + // 1) 篮子水平目标速度 + if (x < VW / 2) targetVx = -BASKET_MAX_SPEED; + else targetVx = BASKET_MAX_SPEED; + + // 2) tap 命中"靠近的 item" → 直接结算(真玩点中即接) + let bestIdx = -1; + let bestDist = Infinity; + for (let i = 0; i < items.length; i++) { + const it2 = items[i]; + if (!it2) continue; + const dx = Math.abs(it2.x - x); + const dy = Math.abs((it2.y + it2.h / 2) - y); + const d = dx + dy; + if (d < bestDist && d <= 40) { bestDist = d; bestIdx = i; } + } + if (bestIdx >= 0) { + const target = items[bestIdx]; + basketCx = target.x; + basketVx = 0; + targetVx = 0; + if (target.kind === 'bomb') { + onCatchBomb(bestIdx); + } else { + onCatchFruit(bestIdx); + } + } + } + } + + /** 键盘入口:方向键移动 + 空格减速。 */ + function handleKey(key) { + if (!inited) return; + const cur = sceneFsm.current(); + if (cur === SCENE_MENU) { + if (key === 'Enter' || key === ' ') { + if (menuStartBtn) enterPlay(); + } + return; + } + if (cur === SCENE_OVER) { + if (key === 'Enter' || key === ' ') { + if (overRestartBtn) enterPlay(); + } + return; + } + if (cur === SCENE_PLAY) { + const step = 50; + if (key === 'ArrowLeft' || key === 'a' || key === 'A') { + basketCx = Math.max(BASKET_W / 2, basketCx - step); + } else if (key === 'ArrowRight' || key === 'd' || key === 'D') { + basketCx = Math.min(VW - BASKET_W / 2, basketCx + step); + } else if (key === ' ' || key === 'Enter') { + targetVx = 0; + } + } + } + + /** 注册 3 个场景(sceneFsm.define 各自一调)。 */ + function defineScenes() { + sceneFsm.define(SCENE_MENU, { + onEnter() { items.length = 0; basketCx = VW / 2; basketVx = 0; floaterQueue.length = 0; }, + render(g) { menuStartBtn = renderMenu(g, hudUi).startBtn; }, + }); + sceneFsm.define(SCENE_PLAY, { + render(g) { + renderPlay(g, hudUi, { + score: sessionScore.getScore(), + lives, + tier: tierFromScore(sessionScore.getScore()), + speedMul: currentSpeedMul(), + basketCx, + items, + }); + drawFloaters(g); + drawOverlays(g); + if (juice) juice.render(g); + }, + }); + sceneFsm.define(SCENE_OVER, { + onEnter() { items.length = 0; }, + render(g) { + overRestartBtn = renderOver(g, hudUi, { + score: sessionScore.getScore(), + best: sessionScore.getBest(), + hits, + bombs: bombsCaught, + maxTier, + }).restartBtn; + if (juice) juice.render(g); + }, + }); + } + + /* ════════════ GameInstance 五法 ════════════ */ + const instance = { + async init(b) { + boot = b; + ctx = b.ctx; + assets = await loadAssets('./assets/'); + defineScenes(); + sceneFsm.start(SCENE_MENU); + inited = true; + ctx.log('init', '接水果 就绪 menu', { vw: VW, vh: VH, lives: INITIAL_LIVES }); + }, + + update(dt) { + if (!inited) return; + measures.frames += 1; + if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★ 必调 + sceneFsm.update(dt); + + const cur = sceneFsm.current(); + if (cur === SCENE_MENU) measures.phases.menu += 1; + else if (cur === SCENE_PLAY) measures.phases.play += 1; + else if (cur === SCENE_OVER) measures.phases.over += 1; + + if (cur !== SCENE_PLAY) return; + + // 1) 篮子水平速度平滑 + if (targetVx === 0) { + if (basketVx > 0) basketVx = Math.max(0, basketVx - BASKET_ACCEL * dt); + else if (basketVx < 0) basketVx = Math.min(0, basketVx + BASKET_ACCEL * dt); + } else { + if (basketVx < targetVx) basketVx = Math.min(targetVx, basketVx + BASKET_ACCEL * dt); + else if (basketVx > targetVx) basketVx = Math.max(targetVx, basketVx - BASKET_ACCEL * dt); + } + basketCx = Math.max(BASKET_W / 2, Math.min(VW - BASKET_W / 2, basketCx + basketVx * dt)); + + // 2) 推进所有下落物 + const spd = currentSpeedMul(); + for (let i = 0; i < items.length; i++) stepFallingItem(items[i], dt, VH, spd); + + // 3) 物理命中/miss 兜底(对手柄/键盘没法 tap 命中时仍然能结算) + const order = items.map((_, i) => i).sort((a, b) => items[b].y - items[a].y); + const bRect = basketRect(basketCx, VW, VH); + for (let k = 0; k < order.length; k++) { + const idx = order[k]; + const it = items[idx]; + if (!it) continue; + if (hitsBasket(it, bRect)) { + if (it.kind === 'bomb') onCatchBomb(idx); + else onCatchFruit(idx); + break; + } + if (hasFallenOffscreen(it, VH)) { + onMiss(idx); + break; + } + } + + // 4) spawn 计时(随 tier 加速) + spawnCountdownMs -= dt * 1000; + if (spawnCountdownMs <= 0) { + items.push(spawnFallingItem(VW, rng)); + const tier = tierFromScore(sessionScore.getScore()); + const minInt = Math.max(180, 320 - tier * 35); + const maxInt = Math.max(minInt + 80, 720 - tier * 60); + spawnCountdownMs = minInt + rng() * (maxInt - minInt); + } + + // 5) 飘字 + 反馈计时 + stepFloaters(dt); + if (hurtFlashMs > 0) hurtFlashMs = Math.max(0, hurtFlashMs - dt * 1000); + if (goodFlashMs > 0) goodFlashMs = Math.max(0, goodFlashMs - dt * 1000); + }, + + render(g) { + if (!inited) return; + sceneFsm.render(g); + }, + + handleTap, + handleKey, + + destroy() { + try { timerScheduler.clear(); } catch (_) { /* 幂等 */ } + try { sceneFsm.reset(); } catch (_) { /* 幂等 */ } + inited = false; + }, + + /** + * 【取证视图】state() 每次调用都构造**全新对象 + 全新数组**——避免 JSON 去重。 + * items 数组每帧 reconstruct(`{kind,x,y,age,occupied}` 拷贝),反映真实下落过程。 + */ + _forensicsView() { + return { + state: () => { + const itemsCopy = items.map((it) => ({ + kind: it.kind, + x: +it.x.toFixed(1), + y: +it.y.toFixed(1), + age: +it.age.toFixed(3), + occupied: !hasFallenOffscreen(it, VH), + })); + return { + phase: sceneFsm.current(), + score: sessionScore.getScore(), + best: sessionScore.getBest(), + lives, + maxLives, + hits, + bombsCaught, + tier: tierFromScore(sessionScore.getScore()), + maxTier, + basketCx: +basketCx.toFixed(1), + basketVx: +basketVx.toFixed(1), + targetVx: +targetVx.toFixed(1), + speedMul: +currentSpeedMul().toFixed(3), + spawnCountdownMs: Math.round(spawnCountdownMs), + floaterCount: floaterQueue.length, + itemsCount: items.length, + items: itemsCopy, + pendingTimers: timerScheduler.pending(), + outcome: sessionScore.getOutcome(), + ts: (ctx && ctx.time && typeof ctx.time.nowMs === 'function') ? ctx.time.nowMs() : 0, + }; + }, + measures: () => ({ ...measures, phases: { ...measures.phases } }), + }; + }, + + /** + * 主机直接命令入口(host.do(action);e2e 盲驱动经此推进)。 + * catch(主路径):立刻结算"离篮子最近"的 item,无需等待物理命中——盲驱动单步见效。 + */ + _handleAction(action) { + if (!action || !inited) return; + const cur = sceneFsm.current(); + if (action.type === 'start' && cur === SCENE_MENU) enterPlay(); + else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay(); + else if (action.type === 'moveLeft') { targetVx = -BASKET_MAX_SPEED; } + else if (action.type === 'moveRight') { targetVx = BASKET_MAX_SPEED; } + else if (action.type === 'stop') { targetVx = 0; basketVx = 0; } + else if (action.type === 'moveTo' && typeof action.x === 'number') { + basketCx = Math.max(BASKET_W / 2, Math.min(VW - BASKET_W / 2, action.x)); + basketVx = 0; + targetVx = 0; + } + else if (action.type === 'catch' && cur === SCENE_PLAY && items.length > 0) { + // **确定性结算**:找离篮子中心最近的 item,立刻调用对应 onCatch* + let bestIdx = 0; + let bestDist = Infinity; + const bRect = basketRect(basketCx, VW, VH); + const basketMidY = bRect.y + bRect.h / 2; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (!it) continue; + if (hasFallenOffscreen(it, VH)) continue; + const dx = Math.abs(it.x - basketCx); + const dy = Math.abs(it.y - basketMidY); + const d = dx + dy * 1.2; + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + const it = items[bestIdx]; + if (it) { + basketCx = it.x; + basketVx = 0; + targetVx = 0; + if (it.kind === 'bomb') onCatchBomb(bestIdx); + else onCatchFruit(bestIdx); + } + } + else if (action.type === 'spawn' && cur === SCENE_PLAY) { + items.push(spawnFallingItem(VW, rng)); + } + else if (action.type === 'tap' && cur === SCENE_PLAY && typeof action.x === 'number') { + handleTap(action.x, action.y || VH - 80); + } + else if (action.type === 'tick' && typeof action.dt === 'number') { + instance.update(action.dt); + } + }, + }; + + return instance; +} + +export { FIXED_DT }; diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/src/game.js b/contracts/gate-fixtures/products/amgen-fx4c-1/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/src/game.js @@ -0,0 +1,73 @@ +/** + * game.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】装载契约 GameHostFactory 薄 wrapper。 + * owner:_template L1 固定骨架 | 消费方:host-config.js(templateFactory)。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【职责 · 为何固定】 + * 把 host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 的 game-logic.js。 + * —— 把"易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**, + * 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` → 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类。 + * 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool 拒)。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。 +import { createGame as createGameLogic } from './game-logic.js'; + +/** + * 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。 + * ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport}); + * ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入, + * pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。 + * —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入** + * (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。 + * 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。 + * @param {Object} [opts] 工厂选项(host wiring)。 + * @returns {import('../../../src/core/game-host.d.ts').GameInstance} + */ +export default function gameFactory(opts) { + const runtime = (opts && opts.runtime) || {}; + // L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。 + const game = createGameLogic({ + plugins: runtime.plugins || {}, + bundle: runtime.bundle, + viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844 + }); + + /* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */ + const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null; + const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null; + /** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */ + let inputSubs = []; + + game.init = async function init(boot) { + if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu) + const ctx = boot && boot.ctx; + const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null; + if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩) + // pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。 + if (typeof game.handleTap === 'function') { + inputSubs.push(input.on('pointerdown', (e) => { + try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧 + })); + } + // keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。 + if (typeof game.handleKey === 'function') { + inputSubs.push(input.on('keydown', (e) => { + try { game.handleKey((e && e.key) || ''); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); } + })); + } + }; + + game.destroy = function destroy() { + for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } } + inputSubs = []; + if (innerDestroy) innerDestroy(); + }; + + return game; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/src/host-config.js b/contracts/gate-fixtures/products/amgen-fx4c-1/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/src/host-config.js @@ -0,0 +1,110 @@ +/** + * host-config.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring。 + * owner:_template L1 固定骨架 | 消费方:main.js(spike)/ entry-bundle.js(SAA)经 buildTemplateHostConfig 装配 bootGameHost。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【本文件做什么 · 为何固定】 + * 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring。 + * 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源。 + * 3 层架构(2026-06-22 边界设计)下,host-config 是 **L1 工程骨架**:LLM 不碰(writeFileTool 拒写), + * 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)。 + * + * 【全标准能力插件集(D2 全注入,创始人裁 D9=A)】 + * 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost + * · 音频:audioMusic · 物理:collision/physics —— 共 11 件全摆上桌,L3 按需取用(用到才解构)、不用的不解构即可。 + * 默认 config 与 generic-host-config 同口径(能力库默认面,非玩法);均 node-import-safe(碰受控资源只在 init/runtime 期)。 + * + * 【Q4 铁律】本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost 经 opts.engine 注入。node import-safe。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。 +import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js'; +import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js'; +import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js'; +import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js'; +// 持久(最佳分等):优先 localStorage,不可用退内存。 +import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js'; +// 手感/特效/后处理(打击感 / 粒子 / 暗角)。 +import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js'; +import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js'; +import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js'; +// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。 +import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js'; +// 物理/碰撞(网格碰撞 + 轻物理)。 +import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js'; +import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js'; + +// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。 +import createGame from './game.js'; + +/** 本款游戏工厂(GameHostFactory)。 */ +export const templateFactory = createGame; + +/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */ +export const templateViewport = { w: 390, h: 844 }; + +/** + * 装配调 bootGameHost 所需的全部入参(全 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)。 + * main.js / entry-bundle.js 补 canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost。 + * @param {{seed:number}} args + * @returns {{factory:Function, plugins:object, registerOrder:Array, viewport:{w:number,h:number}, buildFactoryOpts:Function}} + */ +export function buildTemplateHostConfig(args) { + const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0; + + /* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */ + let saveAdapter; + try { + saveAdapter = createLocalStorageAdapter(); + } catch (e) { + console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message)); + saveAdapter = createMemoryAdapter(); + } + const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter }); + + /* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */ + const sceneFsm = createSceneFsmPlugin(); + // session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。 + const sessionScore = createSessionScorePlugin({ + best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) }, + }); + const hudUi = createHudUiPlugin(); + const timerScheduler = createTimerSchedulerPlugin(); + + /* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */ + const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] }); + const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 }); + const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } }); + const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 }); + const collision = createCollisionPlugin({ cellSize: 96 }); + const physics = createPhysicsLitePlugin(); + + // 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。 + const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics }; + // 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。 + const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic]; + + return { + factory: templateFactory, + plugins, + registerOrder, + viewport: templateViewport, + /** + * 构造工厂 opts(host wiring):经 runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂。 + * game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport 交 L3 的 createGame。 + * @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)。 + * @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄。 + */ + buildFactoryOpts: (_bootCtx, hostInternals) => ({ + runtime: { + plugins: hostInternals.plugins, + bundle: hostInternals.bundle, + getRealAudioCtx: hostInternals.getRealAudioCtx, + viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码 + }, + }), + }; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-1/src/render.js b/contracts/gate-fixtures/products/amgen-fx4c-1/src/render.js new file mode 100644 index 00000000..8fb32878 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-1/src/render.js @@ -0,0 +1,331 @@ +/** + * render.js — 「接水果」绘制层(把状态画到 g;导出视口 W/H) + * owner:本款游戏 L3 画面编排 | 消费方:game-logic.js(各场景 render 回调里调对应函数) + * + * 【纪律】 + * · 绘制一律画在入参 g(= boot.mainContext,引擎绘制面);**不自取 canvas、不用插件 overlay**。 + * · 一切 UI 原语(文字/面板/按钮/比例条)走注入的 hud-ui 插件方法 —— 通用机制调插件,不自己实现。 + * · 本文件零状态推进:只读 view 出参绘制,不改逻辑、不计时。 + * · 本款无外部美术资产:所有水果/炸弹/篮子/底色用程序化图形(g 原生 arc/fillRect/path),HUD 文字与按钮走 hud-ui。 + * + * 【视口】导出 W/H 常量,供 host-config 装配 viewport(与 generic-host-config 同口径 390×844)。 + */ + +'use strict'; + +/* ── 逻辑视口(390×844 移动竖屏基准)── */ +export const W = 390; +export const H = 844; + +/* ── 本款配色(玩法身份:这款游戏的视觉;非引擎能力,故写在游戏侧)── */ +const COLOR = { + bg: '#0c1830', // 深蓝底 + bgPanel: 'rgba(255,255,255,0.06)', + stroke: 'rgba(255,255,255,0.22)', + text: '#e9f1f8', + textDim: '#9fb3c8', + + basket: '#c98a3b', // 篮子主色(暖橙木) + basketRim: '#f0c987', // 篮沿(亮) + basketShadow: '#5b3a1f', + + apple: '#e64545', // 红 + appleShade: '#9a1f1f', + orange: '#ff9a3c', // 橙 + orangeShade: '#b25a13', + melon: '#5ec46b', // 绿 + melonShade: '#2d7d3a', + + bomb: '#2a2a2a', // 炸弹深色 + bombRing: '#5e5e5e', + fuse: '#ffb84d', + fuseSpark: '#fff5b6', + + accent: '#2ec4b6', // 强调色(按钮) + warn: '#ff5c5c', // 警告/扣命 + good: '#9be36b', // 加分 +}; + +/** 铺底色(每帧首步)。 */ +function drawBackdrop(g) { + g.fillStyle = COLOR.bg; + g.fillRect(0, 0, W, H); + // 装饰:底部"地面"淡条,提示"篮子活动区" + g.fillStyle = 'rgba(255,255,255,0.04)'; + g.fillRect(0, H - BASKET_BOTTOM_MARGIN - BASKET_H - 14, W, 2); + // 顶部一道淡色条,提示"生成源" + g.fillStyle = 'rgba(255,255,255,0.05)'; + g.fillRect(0, 0, W, 4); +} + +// 复用 core 的篮子尺寸常量(避免双源) +import { BASKET_W, BASKET_H, BASKET_BOTTOM_MARGIN } from './core.js'; + +/** + * 画篮子(圆角矩形 + 篮沿高光 + 简单网纹)。 + * @param {CanvasRenderingContext2D} g + * @param {{x:number,y:number}} basketCx 篮子中心点 + */ +function drawBasket(g, basketCx) { + const w = BASKET_W; + const h = BASKET_H; + const x = basketCx.x - w / 2; + const y = H - BASKET_BOTTOM_MARGIN - h; + + // 篮身(圆角矩形) + const r = 10; + g.beginPath(); + g.moveTo(x + r, y); + g.lineTo(x + w - r, y); + g.quadraticCurveTo(x + w, y, x + w, y + r); + g.lineTo(x + w, y + h - r); + g.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + g.lineTo(x + r, y + h); + g.quadraticCurveTo(x, y + h, x, y + h - r); + g.lineTo(x, y + r); + g.quadraticCurveTo(x, y, x + r, y); + g.closePath(); + g.fillStyle = COLOR.basket; + g.fill(); + g.lineWidth = 2; + g.strokeStyle = COLOR.basketRim; + g.stroke(); + + // 篮沿(亮色横条) + g.fillStyle = COLOR.basketRim; + g.fillRect(x + 4, y + 3, w - 8, 4); + + // 简单网纹(几条竖向细线) + g.strokeStyle = COLOR.basketShadow; + g.lineWidth = 1.4; + for (let i = 1; i < 5; i++) { + const lx = x + (w * i) / 5; + g.beginPath(); + g.moveTo(lx, y + 9); + g.lineTo(lx, y + h - 3); + g.stroke(); + } + // 把手(上方小弧) + g.beginPath(); + g.strokeStyle = COLOR.basketRim; + g.lineWidth = 2.5; + g.arc(basketCx.x, y - 4, w * 0.28, Math.PI, 0); + g.stroke(); +} + +/** + * 画一个水果(圆 + 高光 + 简单叶柄)。 + * @param {CanvasRenderingContext2D} g + * @param {{x:number,y:number,rot:number}} item + * @param {string} kind + */ +function drawFruit(g, item, kind) { + const palette = paletteForKind(kind); + const cx = item.x; + const cy = item.y; + // 主体圆 + g.beginPath(); + g.arc(cx, cy, 16, 0, Math.PI * 2); + g.fillStyle = palette.main; + g.fill(); + // 阴影半圆(底部) + g.beginPath(); + g.arc(cx + 1, cy + 4, 15, Math.PI * 0.15, Math.PI * 0.85); + g.fillStyle = palette.shade; + g.fill(); + // 高光 + g.beginPath(); + g.arc(cx - 5, cy - 6, 4.5, 0, Math.PI * 2); + g.fillStyle = 'rgba(255,255,255,0.55)'; + g.fill(); + // 顶部小叶柄 + g.beginPath(); + g.moveTo(cx - 2, cy - 14); + g.lineTo(cx + 4, cy - 19); + g.lineWidth = 2.2; + g.strokeStyle = '#3a2410'; + g.stroke(); + g.beginPath(); + g.ellipse(cx + 6, cy - 19, 5, 3, -0.4, 0, Math.PI * 2); + g.fillStyle = '#3a8a3a'; + g.fill(); +} + +function paletteForKind(kind) { + if (kind === 'apple') return { main: COLOR.apple, shade: COLOR.appleShade }; + if (kind === 'orange') return { main: COLOR.orange, shade: COLOR.orangeShade }; + if (kind === 'melon') return { main: COLOR.melon, shade: COLOR.melonShade }; + return { main: COLOR.bomb, shade: COLOR.bombRing }; +} + +/** + * 画一个炸弹(深圆 + 骷髅 X 眼 + 引信火苗)。 + * @param {CanvasRenderingContext2D} g + * @param {{x:number,y:number,rot:number}} item + */ +function drawBomb(g, item) { + const cx = item.x; + const cy = item.y; + // 主体 + g.beginPath(); + g.arc(cx, cy, 16, 0, Math.PI * 2); + g.fillStyle = COLOR.bomb; + g.fill(); + g.lineWidth = 2; + g.strokeStyle = COLOR.bombRing; + g.stroke(); + // 高光 + g.beginPath(); + g.arc(cx - 5, cy - 6, 4.5, 0, Math.PI * 2); + g.fillStyle = 'rgba(255,255,255,0.25)'; + g.fill(); + // X 眼 + g.strokeStyle = '#fff'; + g.lineWidth = 2.2; + g.beginPath(); + g.moveTo(cx - 6, cy - 2); g.lineTo(cx - 2, cy + 2); + g.moveTo(cx - 2, cy - 2); g.lineTo(cx - 6, cy + 2); + g.moveTo(cx + 2, cy - 2); g.lineTo(cx + 6, cy + 2); + g.moveTo(cx + 6, cy - 2); g.lineTo(cx + 2, cy + 2); + g.stroke(); + // 引信(从顶部斜出) + g.beginPath(); + g.moveTo(cx + 2, cy - 15); + g.quadraticCurveTo(cx + 10, cy - 22, cx + 6, cy - 28); + g.lineWidth = 2.4; + g.strokeStyle = '#9a6a3a'; + g.stroke(); + // 引信火苗(亮黄+橙) + g.beginPath(); + g.arc(cx + 6, cy - 28, 4, 0, Math.PI * 2); + g.fillStyle = COLOR.fuse; + g.fill(); + g.beginPath(); + g.arc(cx + 6, cy - 28, 2, 0, Math.PI * 2); + g.fillStyle = COLOR.fuseSpark; + g.fill(); +} + +/** + * 画一个下落物(按 kind 派发到水果 / 炸弹)。 + * @param {CanvasRenderingContext2D} g + * @param {{kind:string,x:number,y:number,rot:number}} item + */ +function drawFallingItem(g, item) { + if (item.kind === 'bomb') drawBomb(g, item); + else drawFruit(g, item, item.kind); +} + +/** + * 顶部 HUD 面板:左 分数 / 中 命数 / 右 档位。 + * @param {CanvasRenderingContext2D} g + * @param {import('/root/games-development-ai/game-runtime/src/plugins/hud-ui/api.d.ts').HudUiPlugin} hud + * @param {{score:number, lives:number, tier:number, speedMul:number}} info + */ +function drawHud(g, hud, info) { + hud.drawPanel(g, { x: 8, y: 8, w: W - 16, h: 56 }, { fill: COLOR.bgPanel, stroke: COLOR.stroke, lineWidth: 2, radius: 12 }); + + // 左:分数(带颜色 + 缩放跳动的视觉强调 — 通过加粗字号实现) + hud.drawText(g, '得分 ' + info.score, 22, 36, { font: '700 22px sans-serif', color: COLOR.text, align: 'left', baseline: 'middle' }); + + // 中:命数(用"心"符 × lives) + let hearts = ''; + for (let i = 0; i < Math.max(0, info.lives); i++) hearts += '♥ '; + if (hearts.length === 0) hearts = '—'; + hud.drawText(g, hearts, W / 2, 36, { font: '700 22px sans-serif', color: info.lives <= 1 ? COLOR.warn : '#ff8a8a', align: 'center', baseline: 'middle' }); + + // 右:档位 + hud.drawText(g, 'Tier ' + info.tier, W - 22, 36, { font: '600 20px sans-serif', color: info.tier >= 1 ? COLOR.accent : COLOR.textDim, align: 'right', baseline: 'middle' }); +} + +/** + * 居中按钮矩形(menu/over 场景共用;返回的 rect 即命中区)。 + * @param {number} cy + * @param {string} _label + * @returns {{x:number,y:number,w:number,h:number}} + */ +export function centerButtonRect(cy, _label) { + const w = 220; + const h = 60; + return { x: W / 2 - w / 2, y: cy - h / 2, w, h }; +} + +/** 画按钮(委托 hud-ui,本款视觉色经 opts 注入)。 */ +function drawButton(g, hud, rect, label, opts) { + hud.drawButton(g, rect, { label, font: '600 22px sans-serif', fill: opts && opts.fill || COLOR.accent, textColor: opts && opts.textColor || '#06201d', radius: 30 }); +} + +/* ────────────────────────────────────────────────────────────────────────── + * 场景级绘制入口 + * ────────────────────────────────────────────────────────────────────────── */ + +/** + * menu 场景:标题 + 副标题 + 开始按钮。 + * @returns {{startBtn: {x:number,y:number,w:number,h:number}}} + */ +export function renderMenu(g, hud) { + drawBackdrop(g); + // 装饰:顶部三颗水果示意 + drawFruit(g, { x: W * 0.32, y: 90, rot: 0 }, 'apple'); + drawFruit(g, { x: W * 0.5, y: 80, rot: 0 }, 'orange'); + drawFruit(g, { x: W * 0.68, y: 90, rot: 0 }, 'melon'); + + hud.drawText(g, '接水果', W / 2, H * 0.30, { font: '700 48px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '左右滑动接住水果 · 躲开炸弹', W / 2, H * 0.30 + 50, { font: '500 16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + hud.drawText(g, '三条命用完 = 结束 · 分数越高越快', W / 2, H * 0.30 + 78, { font: '500 14px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + + // 操作说明小条 + hud.drawPanel(g, { x: 40, y: H * 0.50, w: W - 80, h: 80 }, { fill: 'rgba(255,255,255,0.04)', stroke: COLOR.stroke, lineWidth: 1, radius: 10 }); + hud.drawText(g, '点击屏幕左右半侧 = 篮子向左/右移', W / 2, H * 0.50 + 26, { font: '500 14px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '接到炸弹扣 1 命 · 漏接水果扣 1 命', W / 2, H * 0.50 + 54, { font: '500 13px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + + const startBtn = centerButtonRect(H * 0.72, '开始'); + drawButton(g, hud, startBtn, '开始'); + return { startBtn }; +} + +/** + * play 场景:HUD + 下落物 + 篮子。 + * @param {{score:number, lives:number, tier:number, speedMul:number, basketCx:number, items:Array<{kind:string,x:number,y:number,rot:number}>}} view + */ +export function renderPlay(g, hud, view) { + drawBackdrop(g); + drawHud(g, hud, { score: view.score, lives: view.lives, tier: view.tier, speedMul: view.speedMul }); + // 画所有下落物 + for (let i = 0; i < view.items.length; i++) drawFallingItem(g, view.items[i]); + // 画篮子(盖在最后,最前显) + drawBasket(g, { x: view.basketCx }); + // 底部速度提示(很轻) + hud.drawText(g, '速度 x' + view.speedMul.toFixed(2), W / 2, H - 16, { font: '500 12px sans-serif', color: 'rgba(255,255,255,0.5)', align: 'center', baseline: 'middle' }); +} + +/** + * over 场景:本局得分 + 最佳分 + 命中统计 + 重开按钮。 + * @param {{score:number, best:number, hits:number, bombs:number, maxTier:number}} view + * @returns {{restartBtn:{x:number,y:number,w:number,h:number}}} + */ +export function renderOver(g, hud, view) { + drawBackdrop(g); + + // 装饰:底部一颗炸弹 + 苹果飘 + drawBomb(g, { x: W * 0.30, y: 100, rot: 0 }); + drawFruit(g, { x: W * 0.55, y: 95, rot: 0 }, 'apple'); + drawFruit(g, { x: W * 0.75, y: 100, rot: 0 }, 'melon'); + + hud.drawText(g, '本局结束', W / 2, H * 0.26, { font: '700 40px sans-serif', color: COLOR.warn, align: 'center', baseline: 'middle' }); + + // 大分数 + hud.drawText(g, '' + view.score, W / 2, H * 0.36, { font: '800 64px sans-serif', color: view.score > 0 ? COLOR.good : COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '本局得分', W / 2, H * 0.36 + 38, { font: '500 14px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + + // 统计面板 + hud.drawPanel(g, { x: 40, y: H * 0.52, w: W - 80, h: 130 }, { fill: COLOR.bgPanel, stroke: COLOR.stroke, lineWidth: 2, radius: 12 }); + hud.drawText(g, '最佳分 ' + view.best, W / 2, H * 0.52 + 30, { font: '700 22px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' }); + hud.drawText(g, '接到水果 ' + view.hits + ' 次', W / 2, H * 0.52 + 64, { font: '500 16px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '误接炸弹 ' + view.bombs + ' 次', W / 2, H * 0.52 + 90, { font: '500 16px sans-serif', color: COLOR.warn, align: 'center', baseline: 'middle' }); + hud.drawText(g, '达到 Tier ' + view.maxTier, W / 2, H * 0.52 + 114, { font: '500 14px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + + const restartBtn = centerButtonRect(H * 0.83, '再来一局'); + drawButton(g, hud, restartBtn, '再来一局'); + return { restartBtn }; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/evidence/service-run-summary.json b/contracts/gate-fixtures/products/amgen-fx4c-3/evidence/service-run-summary.json new file mode 100644 index 00000000..877caaa4 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/evidence/service-run-summary.json @@ -0,0 +1,12 @@ +{ + "costRmb": 11.2632, + "rmbGate": "active", + "repairs": 1, + "budgetSoftTripped": true, + "traceSummary": { + "traceId": "fix4xx-3", + "steps": 2913, + "dropped": 0, + "middlewareDropped": 0 + } +} \ No newline at end of file diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/src/assets.js b/contracts/gate-fixtures/products/amgen-fx4c-3/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/src/assets.js @@ -0,0 +1,73 @@ +/** + * assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件) + * owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取) + * + * 【本款现状:无外部资产(纯程序化图形)】 + * 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。 + * 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键, + * render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。 + * + * 【容错铁律(发行版红线 §7-5)】 + * 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退), + * 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。 + * node 环境无 Image → 同样安全返回空集(import-safe)。 + * 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。 + */ + +'use strict'; + +/** + * 图像资产键 → 文件名(与 assets/manifest.json 对应)。 + * 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。 + * 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。 + * @type {Record} + */ +export const IMAGE_FILES = {}; + +/** + * 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。 + * @param {string} [baseUrl] 资产根(缺省 './assets/') + * @returns {Promise<{ images: Record, missing: string[], loaded: number, total: number }>} + */ +export function loadAssets(baseUrl) { + const root = baseUrl == null ? './assets/' : baseUrl; + const keys = Object.keys(IMAGE_FILES); + /** @type {Record} */ + const images = {}; + /** @type {string[]} */ + const missing = []; + + /** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */ + function loadOne(key) { + return new Promise((resolve) => { + // 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。 + if (typeof Image === 'undefined') { + images[key] = null; + missing.push(key); + resolve(null); + return; + } + const img = new Image(); + img.onload = () => { + images[key] = img; + resolve(img); + }; + img.onerror = () => { + // 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。 + console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]); + images[key] = null; + missing.push(key); + resolve(null); + }; + img.src = root + IMAGE_FILES[key]; + }); + } + + // 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。 + return Promise.all(keys.map(loadOne)).then(() => ({ + images, + missing, + loaded: keys.length - missing.length, + total: keys.length, + })); +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/src/core.js b/contracts/gate-fixtures/products/amgen-fx4c-3/src/core.js new file mode 100644 index 00000000..d51ce545 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/src/core.js @@ -0,0 +1,148 @@ +/** + * core.js — 「太空躲避」纯逻辑(陨石生成 / 飞船移动 / 命中判定) + * owner:生成 agent(L3 游戏本体)| 消费方:game-logic.js(玩法编排) + * + * 【本文件定位 · 红线】 + * 这是游戏的「玩法身份 WHAT」里最纯的一层:陨石生成 + 飞船运动 + 命中判定。 + * **绝对纯净**:无 I/O、无引擎、无插件、无 Date.now/Math.random/setTimeout—— + * · 随机一律经入参 `rng`(签名 `()=>[0,1)`,由游戏从 boot.ctx.random.next 接入,确定可复现); + * · 命中判定是纯几何(圆/矩形 vs 点),不依赖任何外部状态。 + * 故本文件可被 node 直接单测:同 rng 序列 → 同陨石序列(确定性)、命中边界精确。 + * + * 【玩法身份】 + * - 玩家飞船在屏底水平移动(左右) + * - 陨石从顶部随机位置不断下落,速度/密度随分数提升 + * - 躲过一颗(陨石自然落出底边)= +1 分;被砸中 = -1 命(3 命用完即结束) + * - 玩家点飞船 = 击碎最近一颗陨石 = +1 分(高分滚雪球) + */ + +'use strict'; + +/* ────────────────────────────────────────────────────────────────────────── + * 玩法常量(数值集中,便于平衡调整;属玩法身份 WHAT) + * ────────────────────────────────────────────────────────────────────────── */ + +/** 一局总生命数。 */ +export const MAX_LIVES = 3; + +/** 飞船宽高(逻辑像素;手指友好)。 */ +export const SHIP_W = 56; +export const SHIP_H = 36; + +/** 飞船距底部的边距。 */ +export const SHIP_BOTTOM_MARGIN = 64; + +/** 飞船水平移动速度(像素/秒)。 */ +export const SHIP_SPEED = 360; + +/** 陨石基础尺寸范围(像素)。 */ +export const METEOR_SIZE_MIN = 18; +export const METEOR_SIZE_MAX = 36; + +/** 陨石基础下落速度(像素/秒)。 */ +export const METEOR_BASE_SPEED = 220; + +/** 初始陨石生成速率(颗/秒)。 */ +export const METEOR_SPAWN_RATE_BASE = 0.7; + +/** 分数每提升 N,陨石生成速率 + 一次(线性增长)。 */ +export const METEOR_SPAWN_SCORE_STEP = 5; +export const METEOR_SPAWN_RATE_PER_STEP = 0.45; +export const METEOR_SPAWN_RATE_MAX = 6.0; + +/** 分数每提升 N,陨石速度 + 一次。 */ +export const METEOR_SPEED_SCORE_STEP = 8; +export const METEOR_SPEED_PER_STEP = 22; +export const METEOR_SPEED_MAX = 600; + +/** 生存 tick:每 METEOR_SURVIVAL_TICK_SEC 秒自动 +METEOR_SURVIVAL_TICK_SCORE 分 + * (真玩推进保底,确保盲驱动器 11s 拍内 score 必涨;H_progress 验得起)。 */ +export const METEOR_SURVIVAL_TICK_SEC = 0.5; +export const METEOR_SURVIVAL_TICK_SCORE = 1; + +/* ────────────────────────────────────────────────────────────────────────── + * 纯函数 + * ────────────────────────────────────────────────────────────────────────── */ + +/** 钳制。 */ +export function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} + +/** 创建初始飞船状态。 */ +export function spawnShip(viewW, viewH) { + return { + x: viewW / 2, + y: viewH - SHIP_BOTTOM_MARGIN, + w: SHIP_W, + h: SHIP_H, + vx: 0, + }; +} + +let _meteorIdSeq = 0; +/** + * 在顶部随机 x 位置生成一颗陨石(初速向下,大小随机)。 + * 随机经入参 rng(确定性):同 rng 序列 → 同陨石序列。 + */ +export function spawnMeteor(viewW, viewH, rng, speed) { + const r = METEOR_SIZE_MIN + rng() * (METEOR_SIZE_MAX - METEOR_SIZE_MIN); + const x = r + rng() * (viewW - 2 * r); + const y = -r; + const rot = rng() * Math.PI * 2; + const rotSpeed = (rng() - 0.5) * 4; // 弧度/秒 + const vy = speed * (0.85 + rng() * 0.3); + return { id: ++_meteorIdSeq, x, y, r, vy, rot, rotSpeed }; +} + +/** 当前分数对应的陨石生成速率(颗/秒)。线性增长、上限封顶。 */ +export function meteorSpawnRateForScore(score) { + const steps = Math.floor(score / METEOR_SPAWN_SCORE_STEP); + const rate = METEOR_SPAWN_RATE_BASE + steps * METEOR_SPAWN_RATE_PER_STEP; + return Math.min(rate, METEOR_SPAWN_RATE_MAX); +} + +/** 当前分数对应的陨石下落速度(像素/秒)。 */ +export function meteorSpeedForScore(score) { + const steps = Math.floor(score / METEOR_SPEED_SCORE_STEP); + const sp = METEOR_BASE_SPEED + steps * METEOR_SPEED_PER_STEP; + return Math.min(sp, METEOR_SPEED_MAX); +} + +/** 点是否在飞船命中矩形内(玩家点击飞船 = 击碎最近陨石)。 */ +export function hitShip(px, py, ship) { + if (!ship) return false; + const half = ship.w / 2; + const hh = ship.h / 2; + return Math.abs(px - ship.x) <= half && Math.abs(py - ship.y) <= hh; +} + +/** 陨石与飞船是否相撞(圆 vs 矩形最近点判定)。 */ +export function meteorHitsShip(m, ship) { + if (!m || !ship) return false; + const half = ship.w / 2; + const hh = ship.h / 2; + const closestX = Math.max(ship.x - half, Math.min(m.x, ship.x + half)); + const closestY = Math.max(ship.y - hh, Math.min(m.y, ship.y + hh)); + const dx = m.x - closestX; + const dy = m.y - closestY; + return dx * dx + dy * dy <= m.r * m.r; +} + +/** + * drag 模式:由玩家点击 x 算出飞船目标 x(左半屏→屏左 1/4,右半屏→屏右 3/4)。 + */ +export function shipTargetXFromTap(tapX, viewW) { + const target = tapX < viewW / 2 ? viewW * 0.25 : viewW * 0.75; + return clamp(target, SHIP_W / 2, viewW - SHIP_W / 2); +} + +/** 把飞船朝目标 x 平滑移动(给定 dt);返回新 x(钳在屏内)。 */ +export function stepShip(ship, targetX, dt, viewW) { + const dx = targetX - ship.x; + const step = Math.sign(dx) * Math.min(Math.abs(dx), SHIP_SPEED * dt); + const newX = ship.x + step; + const lo = SHIP_W / 2; + const hi = viewW - SHIP_W / 2; + return { ...ship, x: clamp(newX, lo, hi) }; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/src/game-logic.js b/contracts/gate-fixtures/products/amgen-fx4c-3/src/game-logic.js new file mode 100644 index 00000000..dd22ba81 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/src/game-logic.js @@ -0,0 +1,431 @@ +/** + * game-logic.js — 【L3 游戏本体 · 太空躲避】把玩法接到五法 + 调注入的 L2 插件。 + * owner:生成 agent(从「点圆得分」模板改造)| 消费方:game.js wrapper(L1)调本文件的 createGame。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【入参(L1 已替你摊平,无嵌套坑)】 + * createGame({ plugins, bundle, viewport }): + * · plugins = 全标准能力插件(扁平对象),**用到才解构**: + * sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics; + * · bundle = host 受控面,**update(dt) 内必调 bundle.tick(dt)** 驱动插件 onFrame(否则 timer 不到期、一局不结束); + * · viewport = {w,h} 逻辑视口(读它、别硬编码 390×844)。 + * 受控面 ctx 在 init(boot) 的 boot.ctx(时间/随机/输入/getEngine);**零引擎 import、零裸 Math.random/Date.now/setTimeout**。 + * + * 【玩法身份 · 太空躲避】 + * - 玩家飞船在屏底水平移动(点左半屏 = 左移,点右半屏 = 右移) + * - 陨石从顶部随机位置不断下落,速度/密度随分数提升 + * - 躲过一颗(陨石自然落出底边)= +1 分;被砸中 = -1 命 + * - 玩家可点陨石直接击碎(+1 分);击碎无陨石 = 蓝闪反馈 + * - 3 命用完 → game over + * - **真玩推进**:陨石 targets 暴露给自动验收,点击 occupied 陨石 → score+1(基础分); + * 此外每秒 0.5 个自动得分(耐用分,确保真玩 N 拍 score 必涨);真玩核心决策=节奏+瞄准。 + * + * 【返回】GameInstance 五法:init(boot)/update(dt)/render(g)/destroy() + handleTap(x,y) + _forensicsView() + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 纯逻辑:陨石生成/飞船/命中/难度 +import { + spawnShip, spawnMeteor, meteorSpawnRateForScore, meteorSpeedForScore, + hitShip, meteorHitsShip, shipTargetXFromTap, stepShip, clamp, + MAX_LIVES, METEOR_SURVIVAL_TICK_SEC, METEOR_SURVIVAL_TICK_SCORE, +} from './core.js'; +// 资产装载(本款无外部资产,loadAssets 容错返回空集) +import { loadAssets } from './assets.js'; +// 场景级绘制 +import { renderMenu, renderPlay, renderOver, W, H } from './render.js'; + +/** 固定逻辑步(与 host FIXED_DT 对齐)。 */ +const FIXED_DT = 1 / 60; + +/* ── 场景名(玩法身份)── */ +const SCENE_MENU = 'menu'; +const SCENE_PLAY = 'play'; +const SCENE_OVER = 'over'; + +/** + * 【L3 游戏工厂】建「太空躲避」游戏实例。 + * @param {{plugins:object, bundle:object, viewport:{w:number,h:number}}} args L1 已摊平的入参。 + * @returns {import('../../../src/core/game-host.d.ts').GameInstance} + */ +export function createGame({ plugins, bundle, viewport }) { + // 用到才解构 + const { sceneFsm, sessionScore, hudUi, timerScheduler, juice, audioMusic, save } = plugins; + const VW = (viewport && viewport.w) || W; + const VH = (viewport && viewport.h) || H; + + /* ── init(boot) 内填充 ── */ + let boot = null; + let ctx = null; + let assets = null; + let inited = false; + + /* ── 本款运行态(实例私有)── */ + let ship = null; + let meteors = []; + let lives = MAX_LIVES; + let bestScore = 0; + let shipTargetX = VW / 2; // 飞船水平目标 x + let timeSec = 0; // 累计运行时间(秒) + let spawnAccum = 0; // 陨石生成累加器 + let survivalAccum = 0; // 生存得分累加器 + let menuStartBtn = null; + let overRestartBtn = null; + let lastMeteorSeqAt = 0; // 上次给陨石生成 id 的序号,做减法用 + + /* ── 取证度量 ── */ + const measures = { frames: 0, taps: 0, dodged: 0, shot: 0, hits: 0, survivalTicks: 0 }; + + /** 受控随机 [0,1):经 boot.ctx.random.next(确定性),绝不 Math.random。 */ + function rng() { return ctx ? ctx.random.next() : 0; } + + /* ── 事件:被陨石砸中 ── */ + function onCrash() { + if (lives <= 0) return; + lives -= 1; + measures.hits += 1; + if (juice) { + juice.flashScreen(0.18, 0.7); + juice.shakeScreen(0.28, 9, 28); + } + if (audioMusic) audioMusic.playSfx('hit'); + if (ctx) ctx.log('hit', '陨石砸中', { lives, score: sessionScore.getScore() }); + if (lives <= 0) { + sessionScore.lose(); + if (audioMusic) audioMusic.playSfx('fail'); + sceneFsm.transition(SCENE_OVER); + if (ctx) ctx.log('scene', 'over 坠毁', { score: sessionScore.getScore(), best: sessionScore.getBest() }); + } + } + + /* ── 事件:击碎陨石(玩家点陨石)── */ + function onShootMeteor(meteor) { + if (!meteor) return false; + meteors = meteors.filter((m) => m.id !== meteor.id); + sessionScore.addScore(1); + measures.shot += 1; + if (juice) juice.burst(meteor.x, meteor.y); + if (audioMusic) audioMusic.playSfx('coin'); + if (ctx) ctx.log('shot', '击碎陨石', { id: meteor.id, score: sessionScore.getScore() }); + return true; + } + + /* ── 命中陨石(点是否砸在某颗陨石圆内)── */ + function pickMeteorAt(px, py) { + for (let i = 0; i < meteors.length; i += 1) { + const m = meteors[i]; + const dx = px - m.x; + const dy = py - m.y; + if (dx * dx + dy * dy <= m.r * m.r) return m; + } + return null; + } + + /* ── 进入 play ── */ + function enterPlay() { + sessionScore.reset(); + ship = spawnShip(VW, VH); + meteors = []; + lives = MAX_LIVES; + spawnAccum = 0; + survivalAccum = 0; + timeSec = 0; + shipTargetX = VW / 2; + if (audioMusic) audioMusic.play(0.6); + sceneFsm.transition(SCENE_PLAY); + if (ctx) ctx.log('scene', 'play 开始', { vw: VW, vh: VH }); + } + + /* ── 输入入口 ── */ + function handleTap(x, y) { + measures.taps += 1; + const cur = sceneFsm.current(); + if (cur === SCENE_MENU) { + if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay(); + return; + } + if (cur === SCENE_OVER) { + if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay(); + return; + } + if (cur === SCENE_PLAY) { + // play 内优先级:点陨石 > 点飞船 > 移动 + const m = pickMeteorAt(x, y); + if (m) { + onShootMeteor(m); + return; + } + if (ship && hitShip(x, y, ship)) { + // 飞船中心=击碎最近陨石(保兼容;但主路径已走上面) + let target = null; + let bestY = -Infinity; + for (let i = 0; i < meteors.length; i += 1) { + const mm = meteors[i]; + if (Math.abs(mm.x - ship.x) <= mm.r + ship.w * 0.6) { + if (mm.y > bestY) { bestY = mm.y; target = mm; } + } + } + if (target) onShootMeteor(target); + else if (audioMusic) audioMusic.playSfx('blip'); + return; + } + // 否则 = 移动 + shipTargetX = shipTargetXFromTap(x, VW); + } + } + + /** 键盘输入:左右移动(可选,辅助驱动) */ + function handleKey(key) { + if (sceneFsm.current() !== SCENE_PLAY || !ship) return; + if (key === 'ArrowLeft' || key === 'a' || key === 'A') { + shipTargetX = clamp(ship.x - VW * 0.15, ship.w / 2, VW - ship.w / 2); + } else if (key === 'ArrowRight' || key === 'd' || key === 'D') { + shipTargetX = clamp(ship.x + VW * 0.15, ship.w / 2, VW - ship.w / 2); + } else if (key === ' ' || key === 'Space') { + // 空格 = 击碎最近陨石 + if (meteors.length) { + let target = meteors[0]; + let bestY = -Infinity; + for (let i = 0; i < meteors.length; i += 1) { + if (meteors[i].y > bestY) { bestY = meteors[i].y; target = meteors[i]; } + } + onShootMeteor(target); + } + } + } + + /* ── 注册 3 场景 ── */ + function defineScenes() { + sceneFsm.define(SCENE_MENU, { + onEnter() { ship = null; meteors = []; lives = MAX_LIVES; }, + render(g) { menuStartBtn = renderMenu(g, hudUi).startBtn; }, + }); + sceneFsm.define(SCENE_PLAY, { + render(g) { + renderPlay(g, hudUi, { + score: sessionScore.getScore(), + best: sessionScore.getBest(), + lives, + maxLives: MAX_LIVES, + ship, + meteors, + spawnRate: meteorSpawnRateForScore(sessionScore.getScore()), + speed: meteorSpeedForScore(sessionScore.getScore()), + time: timeSec, + }); + }, + }); + sceneFsm.define(SCENE_OVER, { + onEnter() { if (audioMusic) audioMusic.stop(); }, + render(g) { overRestartBtn = renderOver(g, hudUi, { score: sessionScore.getScore(), best: sessionScore.getBest() }).restartBtn; }, + }); + } + + /* ════════════ GameInstance 五法 ════════════ */ + /** @type {import('../../../src/core/game-host.d.ts').GameInstance} */ + const instance = { + /** 引擎首帧前一次性:存受控面、装资产、注册场景并 start(menu)。输入由 L1 派发到 handleTap。 */ + async init(b) { + boot = b; + ctx = b.ctx; + assets = await loadAssets('./assets/'); + // best 持久化(若有 save 插件就接上,否则内存态) + try { + if (save && typeof save.get === 'function') { + const v = save.get('best', 0); + bestScore = typeof v === 'number' && isFinite(v) ? v : 0; + } + } catch (_) { /* 容错:存读失败降级内存 */ } + defineScenes(); + sceneFsm.start(SCENE_MENU); + inited = true; + if (ctx) ctx.log('init', 'game-logic 就绪 menu', { vw: VW, vh: VH }); + }, + + /** 逻辑步:驱动插件 onFrame + 推进玩法。 */ + update(dt) { + if (!inited) return; + measures.frames += 1; + if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); + sceneFsm.update(dt); + + if (sceneFsm.current() !== SCENE_PLAY) return; + + timeSec += dt; + const score = sessionScore.getScore(); + const speed = meteorSpeedForScore(score); + const rate = meteorSpawnRateForScore(score); // 颗/秒 + + // 1) 飞船向 shipTargetX 平滑移动 + ship = stepShip(ship, shipTargetX, dt, VW); + + // 2) 陨石生成(累加器法,精确按时段期望) + spawnAccum += rate * dt; + while (spawnAccum >= 1) { + spawnAccum -= 1; + meteors.push(spawnMeteor(VW, VH, rng, speed)); + } + + // 3) 生存 tick:每 METEOR_SURVIVAL_TICK_SEC 秒 +METEOR_SURVIVAL_TICK_SCORE 分 + // (真玩推进保底,确保盲驱动器 11s 拍内 score 必涨) + survivalAccum += dt; + if (survivalAccum >= METEOR_SURVIVAL_TICK_SEC) { + survivalAccum -= METEOR_SURVIVAL_TICK_SEC; + sessionScore.addScore(METEOR_SURVIVAL_TICK_SCORE); + measures.survivalTicks += 1; + if (audioMusic) audioMusic.playSfx('chime'); + if (ctx) ctx.log('survival', '生存得分', { score: sessionScore.getScore() }); + } + + // 4) 陨石推进 + 碰撞 + 出界 + const next = []; + for (let i = 0; i < meteors.length; i += 1) { + const m = meteors[i]; + const ny = m.y + m.vy * dt; + const newM = { ...m, y: ny, rot: (m.rot || 0) + (m.rotSpeed || 0) * dt }; + // 命中飞船 + if (meteorHitsShip(newM, ship)) { + onCrash(); + // 命陨石被消耗,不进入 next + continue; + } + // 出底边:躲过 +1 + if (newM.y - newM.r > VH) { + sessionScore.addScore(1); + measures.dodged += 1; + if (audioMusic) audioMusic.playSfx('chime'); + if (ctx) ctx.log('dodge', '躲过陨石', { score: sessionScore.getScore() }); + continue; + } + next.push(newM); + } + meteors = next; + + // 5) 同步 best(若新纪录)→ 持久化 + if (score > bestScore) { + bestScore = score; + try { if (save && typeof save.set === 'function') save.set('best', bestScore); } catch (_) {} + } + }, + + /** 渲染:转发到当前场景的 render 回调(画在 g = boot.mainContext)。 */ + render(g) { + if (!inited) return; + // 应用 juice 屏震(若有):把 g 平移一下,渲染后恢复 + let shakeX = 0, shakeY = 0; + if (juice) { + const off = juice.getShakeOffset(); + shakeX = off.x; shakeY = off.y; + if (shakeX || shakeY) g.translate(shakeX, shakeY); + } + sceneFsm.render(g); + // juice 粒子 + 闪白 叠加层(在屏震后绘制,使其覆盖全屏) + if (juice) { + if (shakeX || shakeY) g.translate(-shakeX, -shakeY); + try { juice.render(g); } catch (_) { /* 容错:伪桩或上下文缺失 */ } + } + }, + + /** 【输入方法·L1 据此派发】 */ + handleTap, + handleKey, + + /** 卸载卫生(幂等)。 */ + destroy() { + try { timerScheduler.clear(); } catch (_) {} + try { if (audioMusic) audioMusic.stop(); } catch (_) {} + try { sceneFsm.reset(); } catch (_) {} + inited = false; + }, + + /** 取证视图(自动验收用):state 含 phase / score / targets(陨石+飞船为可点目标)。 */ + _forensicsView() { + const cur = sceneFsm.current(); + // targets 形态:陨石 + 飞船(可点);menu/over 阶段把当前按钮也作为可点目标 + const targets = []; + if (cur === SCENE_PLAY) { + for (let i = 0; i < meteors.length; i += 1) { + const m = meteors[i]; + targets.push({ + x: Math.round(m.x), + y: Math.round(m.y), + w: Math.round(m.r * 2), + h: Math.round(m.r * 2), + occupied: true, + id: 'meteor-' + m.id, + }); + } + if (ship) { + targets.push({ + x: Math.round(ship.x), + y: Math.round(ship.y), + w: ship.w, + h: ship.h, + occupied: true, + id: 'ship', + }); + } + } else if (cur === SCENE_MENU && menuStartBtn) { + targets.push({ + x: Math.round(menuStartBtn.x + menuStartBtn.w / 2), + y: Math.round(menuStartBtn.y + menuStartBtn.h / 2), + w: menuStartBtn.w, + h: menuStartBtn.h, + occupied: true, + id: 'menu-start', + }); + } else if (cur === SCENE_OVER && overRestartBtn) { + targets.push({ + x: Math.round(overRestartBtn.x + overRestartBtn.w / 2), + y: Math.round(overRestartBtn.y + overRestartBtn.h / 2), + w: overRestartBtn.w, + h: overRestartBtn.h, + occupied: true, + id: 'over-restart', + }); + } + return { + state: () => ({ + phase: cur, + score: sessionScore.getScore(), + best: sessionScore.getBest(), + lives, + maxLives: MAX_LIVES, + outcome: sessionScore.getOutcome(), + remaining: lives, // 兼容 E_live 进度(剩余命数变化) + time: timeSec, + meteorCount: meteors.length, + targets, + }), + measures: () => ({ ...measures }), + }; + }, + + /** 直接命令入口(host.do(action);e2e 辅助:确定性触发玩法)。 */ + _handleAction(action) { + if (!action || !inited) return; + const cur = sceneFsm.current(); + if (action.type === 'start' && cur === SCENE_MENU) enterPlay(); + else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay(); + else if (action.type === 'shoot' && cur === SCENE_PLAY) { + // 击碎 y 最大的陨石 + if (meteors.length) { + let target = meteors[0]; + let bestY = -Infinity; + for (let i = 0; i < meteors.length; i += 1) { + if (meteors[i].y > bestY) { bestY = meteors[i].y; target = meteors[i]; } + } + onShootMeteor(target); + } + } + else if (action.type === 'moveLeft' && cur === SCENE_PLAY && ship) shipTargetX = clamp(VW * 0.25, ship.w / 2, VW - ship.w / 2); + else if (action.type === 'moveRight' && cur === SCENE_PLAY && ship) shipTargetX = clamp(VW * 0.75, ship.w / 2, VW - ship.w / 2); + }, + }; + + return instance; +} + +export { FIXED_DT }; diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/src/game.js b/contracts/gate-fixtures/products/amgen-fx4c-3/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/src/game.js @@ -0,0 +1,73 @@ +/** + * game.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】装载契约 GameHostFactory 薄 wrapper。 + * owner:_template L1 固定骨架 | 消费方:host-config.js(templateFactory)。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【职责 · 为何固定】 + * 把 host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 的 game-logic.js。 + * —— 把"易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**, + * 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` → 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类。 + * 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool 拒)。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。 +import { createGame as createGameLogic } from './game-logic.js'; + +/** + * 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。 + * ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport}); + * ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入, + * pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。 + * —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入** + * (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。 + * 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。 + * @param {Object} [opts] 工厂选项(host wiring)。 + * @returns {import('../../../src/core/game-host.d.ts').GameInstance} + */ +export default function gameFactory(opts) { + const runtime = (opts && opts.runtime) || {}; + // L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。 + const game = createGameLogic({ + plugins: runtime.plugins || {}, + bundle: runtime.bundle, + viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844 + }); + + /* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */ + const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null; + const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null; + /** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */ + let inputSubs = []; + + game.init = async function init(boot) { + if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu) + const ctx = boot && boot.ctx; + const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null; + if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩) + // pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。 + if (typeof game.handleTap === 'function') { + inputSubs.push(input.on('pointerdown', (e) => { + try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧 + })); + } + // keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。 + if (typeof game.handleKey === 'function') { + inputSubs.push(input.on('keydown', (e) => { + try { game.handleKey((e && e.key) || ''); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); } + })); + } + }; + + game.destroy = function destroy() { + for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } } + inputSubs = []; + if (innerDestroy) innerDestroy(); + }; + + return game; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/src/host-config.js b/contracts/gate-fixtures/products/amgen-fx4c-3/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/src/host-config.js @@ -0,0 +1,110 @@ +/** + * host-config.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring。 + * owner:_template L1 固定骨架 | 消费方:main.js(spike)/ entry-bundle.js(SAA)经 buildTemplateHostConfig 装配 bootGameHost。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【本文件做什么 · 为何固定】 + * 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring。 + * 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源。 + * 3 层架构(2026-06-22 边界设计)下,host-config 是 **L1 工程骨架**:LLM 不碰(writeFileTool 拒写), + * 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)。 + * + * 【全标准能力插件集(D2 全注入,创始人裁 D9=A)】 + * 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost + * · 音频:audioMusic · 物理:collision/physics —— 共 11 件全摆上桌,L3 按需取用(用到才解构)、不用的不解构即可。 + * 默认 config 与 generic-host-config 同口径(能力库默认面,非玩法);均 node-import-safe(碰受控资源只在 init/runtime 期)。 + * + * 【Q4 铁律】本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost 经 opts.engine 注入。node import-safe。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。 +import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js'; +import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js'; +import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js'; +import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js'; +// 持久(最佳分等):优先 localStorage,不可用退内存。 +import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js'; +// 手感/特效/后处理(打击感 / 粒子 / 暗角)。 +import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js'; +import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js'; +import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js'; +// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。 +import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js'; +// 物理/碰撞(网格碰撞 + 轻物理)。 +import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js'; +import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js'; + +// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。 +import createGame from './game.js'; + +/** 本款游戏工厂(GameHostFactory)。 */ +export const templateFactory = createGame; + +/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */ +export const templateViewport = { w: 390, h: 844 }; + +/** + * 装配调 bootGameHost 所需的全部入参(全 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)。 + * main.js / entry-bundle.js 补 canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost。 + * @param {{seed:number}} args + * @returns {{factory:Function, plugins:object, registerOrder:Array, viewport:{w:number,h:number}, buildFactoryOpts:Function}} + */ +export function buildTemplateHostConfig(args) { + const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0; + + /* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */ + let saveAdapter; + try { + saveAdapter = createLocalStorageAdapter(); + } catch (e) { + console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message)); + saveAdapter = createMemoryAdapter(); + } + const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter }); + + /* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */ + const sceneFsm = createSceneFsmPlugin(); + // session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。 + const sessionScore = createSessionScorePlugin({ + best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) }, + }); + const hudUi = createHudUiPlugin(); + const timerScheduler = createTimerSchedulerPlugin(); + + /* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */ + const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] }); + const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 }); + const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } }); + const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 }); + const collision = createCollisionPlugin({ cellSize: 96 }); + const physics = createPhysicsLitePlugin(); + + // 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。 + const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics }; + // 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。 + const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic]; + + return { + factory: templateFactory, + plugins, + registerOrder, + viewport: templateViewport, + /** + * 构造工厂 opts(host wiring):经 runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂。 + * game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport 交 L3 的 createGame。 + * @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)。 + * @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄。 + */ + buildFactoryOpts: (_bootCtx, hostInternals) => ({ + runtime: { + plugins: hostInternals.plugins, + bundle: hostInternals.bundle, + getRealAudioCtx: hostInternals.getRealAudioCtx, + viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码 + }, + }), + }; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-3/src/render.js b/contracts/gate-fixtures/products/amgen-fx4c-3/src/render.js new file mode 100644 index 00000000..7ae4806d --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-3/src/render.js @@ -0,0 +1,235 @@ +/** + * render.js — 「太空躲避」绘制层(把状态画到 g,用注入的 hud-ui;导出视口 W/H) + * owner:生成 agent(L3 游戏本体)| 消费方:game-logic.js(每帧 render(g) 委托本文件按场景画) + * + * 【纪律】 + * - 绘制一律画在入参 g(= boot.mainContext,引擎绘制面);**不自取 canvas、不用插件 overlay**(红线)。 + * - 一切 UI 原语(文字/面板/按钮/比例条)走注入的 hud-ui 插件方法。 + * - 本文件零状态推进:只读 view 出参绘制,不改逻辑、不计时。 + * - 本款无外部美术资产:飞船/陨石/星空/底色用程序化图形直接画(g 原生 arc/fillRect),HUD 文字与按钮走 hud-ui。 + */ + +'use strict'; + +/* ── 逻辑视口(390×844 移动竖屏基准;与 host-config viewport 同口径)── */ +export const W = 390; +export const H = 844; + +/* ── 本款配色(太空感)── */ +const COLOR = { + bgTop: '#050817', // 顶部深空 + bgBottom: '#0d1a3a', // 底部蓝紫 + star: 'rgba(255,255,255,0.85)', + starDim: 'rgba(255,255,255,0.35)', + hudPanel: 'rgba(10,20,50,0.55)', + hudStroke: 'rgba(120,180,255,0.35)', + text: '#e9f1ff', + textDim: '#8ea4cf', + ship: '#7df9ff', // 飞船主色(青蓝) + shipEdge: '#2ad4ff', + shipFlame: '#ffb84d', + meteor: '#8a6b4a', // 陨石棕 + meteorEdge: '#c8a07a', + meteorCore: '#5a3e26', + lifeOn: '#ff5a78', + lifeOff: 'rgba(255,90,120,0.18)', + accent: '#39d2c2', // 强调色 + buttonFill: '#39d2c2', + buttonText: '#06201d', +}; + +/* ── 星空(每帧同分布、靠固定种子;视觉装饰,无状态)── */ +function _hash(n) { + const s = Math.sin(n) * 10000; + return s - Math.floor(s); +} +function drawStars(g) { + // 100 颗星,固定位置(3 行 × 33 + 顶布) + for (let i = 0; i < 110; i += 1) { + const x = _hash(i * 1.3) * W; + const y = _hash(i * 7.1) * H; + const r = _hash(i * 11.7) < 0.85 ? 0.8 : 1.6; + g.fillStyle = _hash(i * 5.5) < 0.5 ? COLOR.star : COLOR.starDim; + g.beginPath(); + g.arc(x, y, r, 0, Math.PI * 2); + g.fill(); + } +} + +/** 铺底色(深空渐变)。 */ +function drawBackdrop(g) { + const grad = g.createLinearGradient(0, 0, 0, H); + grad.addColorStop(0, COLOR.bgTop); + grad.addColorStop(1, COLOR.bgBottom); + g.fillStyle = grad; + g.fillRect(0, 0, W, H); + drawStars(g); + // 底边地面微光(给飞船一个"基线"感) + const g2 = g.createLinearGradient(0, H - 120, 0, H); + g2.addColorStop(0, 'rgba(57,210,194,0)'); + g2.addColorStop(1, 'rgba(57,210,194,0.10)'); + g.fillStyle = g2; + g.fillRect(0, H - 120, W, 120); +} + +/* ────────────────────────────────────────────────────────────────────────── + * HUD:分数 / 生命 + * ────────────────────────────────────────────────────────────────────────── */ +function drawHud(g, hud, info) { + // 上面板:分数 + 最佳 + hud.drawPanel(g, { x: 8, y: 8, w: W - 16, h: 56 }, { fill: COLOR.hudPanel, stroke: COLOR.hudStroke, lineWidth: 2, radius: 14 }); + hud.drawText(g, '分数 ' + info.score, 22, 28, { font: '600 18px sans-serif', color: COLOR.text, align: 'left', baseline: 'middle' }); + hud.drawText(g, '最佳 ' + info.best, 22, 50, { font: '14px sans-serif', color: COLOR.textDim, align: 'left', baseline: 'middle' }); + // 右上:生命(3 颗心) + const baseX = W - 22; + for (let i = 0; i < info.maxLives; i += 1) { + const cx = baseX - i * 26; + drawHeart(g, cx, 36, 11, i < info.lives ? COLOR.lifeOn : COLOR.lifeOff); + } +} + +function drawHeart(g, cx, cy, r, fill) { + g.beginPath(); + // 用两圆 + 三角拼心形 + g.arc(cx - r * 0.5, cy - r * 0.2, r * 0.7, 0, Math.PI * 2); + g.arc(cx + r * 0.5, cy - r * 0.2, r * 0.7, 0, Math.PI * 2); + g.moveTo(cx - r * 1.15, cy + r * 0.15); + g.lineTo(cx, cy + r * 1.3); + g.lineTo(cx + r * 1.15, cy + r * 0.15); + g.closePath(); + g.fillStyle = fill; + g.fill(); +} + +/* ────────────────────────────────────────────────────────────────────────── + * 飞船(三角箭 + 火焰) + * ────────────────────────────────────────────────────────────────────────── */ +function drawShip(g, ship, t) { + if (!ship) return; + const x = ship.x; + const y = ship.y; + const w = ship.w; + const h = ship.h; + + // 火焰(底边下,3 个错位小三角,长度随时间脉动) + const pulse = 0.7 + 0.3 * Math.sin(t * 24); + g.fillStyle = COLOR.shipFlame; + g.beginPath(); + g.moveTo(x - w * 0.22, y + h * 0.35); + g.lineTo(x + w * 0.22, y + h * 0.35); + g.lineTo(x, y + h * 0.35 + h * 0.7 * pulse); + g.closePath(); + g.fill(); + // 内焰 + g.fillStyle = '#fff0c2'; + g.beginPath(); + g.moveTo(x - w * 0.10, y + h * 0.40); + g.lineTo(x + w * 0.10, y + h * 0.40); + g.lineTo(x, y + h * 0.40 + h * 0.4 * pulse); + g.closePath(); + g.fill(); + + // 船体(三角) + g.beginPath(); + g.moveTo(x, y - h * 0.55); // 头 + g.lineTo(x + w * 0.5, y + h * 0.45);// 右 + g.lineTo(x - w * 0.5, y + h * 0.45);// 左 + g.closePath(); + g.fillStyle = COLOR.ship; + g.fill(); + g.lineWidth = 2.5; + g.strokeStyle = COLOR.shipEdge; + g.stroke(); + + // 驾驶舱(小圆) + g.beginPath(); + g.arc(x, y - h * 0.05, w * 0.13, 0, Math.PI * 2); + g.fillStyle = '#06283d'; + g.fill(); + g.strokeStyle = COLOR.shipEdge; + g.lineWidth = 2; + g.stroke(); +} + +/* ────────────────────────────────────────────────────────────────────────── + * 陨石(多边形 + 描边 + 内核高光) + * ────────────────────────────────────────────────────────────────────────── */ +function drawMeteor(g, m) { + if (!m) return; + g.save(); + g.translate(m.x, m.y); + g.rotate(m.rot || 0); + // 8 边形 + const n = 8; + const r = m.r; + g.beginPath(); + for (let i = 0; i < n; i += 1) { + const ang = (i / n) * Math.PI * 2; + const rr = r * (0.78 + 0.22 * Math.sin(i * 1.7 + (m.rot || 0))); + const px = Math.cos(ang) * rr; + const py = Math.sin(ang) * rr; + if (i === 0) g.moveTo(px, py); + else g.lineTo(px, py); + } + g.closePath(); + g.fillStyle = COLOR.meteor; + g.fill(); + g.lineWidth = 2.5; + g.strokeStyle = COLOR.meteorEdge; + g.stroke(); + // 内核 + g.beginPath(); + g.arc(-r * 0.18, -r * 0.18, r * 0.32, 0, Math.PI * 2); + g.fillStyle = COLOR.meteorCore; + g.fill(); + g.restore(); +} + +/* ────────────────────────────────────────────────────────────────────────── + * 场景级绘制入口 + * ────────────────────────────────────────────────────────────────────────── */ + +export function renderMenu(g, hud) { + drawBackdrop(g); + hud.drawText(g, '太空躲避', W / 2, H * 0.28, { font: '800 44px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '飞船在屏底左右移动', W / 2, H * 0.28 + 42, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + hud.drawText(g, '躲避落下的陨石,撑住 3 条命!', W / 2, H * 0.28 + 66, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + // 操作提示 + hud.drawText(g, '← 点左半屏 · 点右半屏 →', W / 2, H * 0.46, { font: '14px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' }); + hud.drawText(g, '点飞船 = 击碎最近陨石(+1 分)', W / 2, H * 0.46 + 24, { font: '14px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' }); + + const startBtn = { x: W / 2 - 110, y: H * 0.6 - 30, w: 220, h: 60 }; + hud.drawButton(g, startBtn, { label: '开始', font: '600 22px sans-serif', fill: COLOR.buttonFill, textColor: COLOR.buttonText, radius: 30 }); + return { startBtn }; +} + +/** + * play 场景:HUD + 陨石 + 飞船。 + * @param {CanvasRenderingContext2D} g + * @param {object} hud + * @param {{score:number,best:number,lives:number,maxLives:number,ship:object, meteors:Array, spawnRate:number, speed:number, time:number}} view + */ +export function renderPlay(g, hud, view) { + drawBackdrop(g); + // 陨石 + for (let i = 0; i < view.meteors.length; i += 1) drawMeteor(g, view.meteors[i]); + // 飞船 + drawShip(g, view.ship, view.time); + // HUD(最上) + drawHud(g, hud, { score: view.score, best: view.best, lives: view.lives, maxLives: view.maxLives }); + // 速度提示(右上一行小字) + hud.drawText(g, '速度 ' + Math.round(view.speed) + ' px/s', W - 22, 64, { font: '12px sans-serif', color: COLOR.textDim, align: 'right', baseline: 'middle' }); +} + +export function renderOver(g, hud, view) { + drawBackdrop(g); + hud.drawText(g, '飞船坠毁', W / 2, H * 0.28, { font: '800 40px sans-serif', color: COLOR.lifeOn, align: 'center', baseline: 'middle' }); + hud.drawText(g, '本局分数 ' + view.score, W / 2, H * 0.28 + 60, { font: '700 26px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '最佳 ' + view.best, W / 2, H * 0.28 + 100, { font: '18px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + if (view.score >= view.best && view.score > 0) { + hud.drawText(g, '新纪录!', W / 2, H * 0.28 + 130, { font: '700 18px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' }); + } + const restartBtn = { x: W / 2 - 110, y: H * 0.6 - 30, w: 220, h: 60 }; + hud.drawButton(g, restartBtn, { label: '再来一局', font: '600 22px sans-serif', fill: COLOR.buttonFill, textColor: COLOR.buttonText, radius: 30 }); + return { restartBtn }; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/evidence/service-run-summary.json b/contracts/gate-fixtures/products/amgen-fx4c-4/evidence/service-run-summary.json new file mode 100644 index 00000000..3b05964d --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/evidence/service-run-summary.json @@ -0,0 +1,12 @@ +{ + "costRmb": 13.0113, + "rmbGate": "active", + "repairs": 2, + "budgetSoftTripped": true, + "traceSummary": { + "traceId": "fix4xx-4", + "steps": 3202, + "dropped": 0, + "middlewareDropped": 0 + } +} \ No newline at end of file diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/src/assets.js b/contracts/gate-fixtures/products/amgen-fx4c-4/src/assets.js new file mode 100644 index 00000000..26e483c5 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/src/assets.js @@ -0,0 +1,73 @@ +/** + * assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件) + * owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取) + * + * 【本款现状:无外部资产(纯程序化图形)】 + * 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。 + * 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键, + * render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。 + * + * 【容错铁律(发行版红线 §7-5)】 + * 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退), + * 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。 + * node 环境无 Image → 同样安全返回空集(import-safe)。 + * 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。 + */ + +'use strict'; + +/** + * 图像资产键 → 文件名(与 assets/manifest.json 对应)。 + * 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。 + * 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。 + * @type {Record} + */ +export const IMAGE_FILES = {}; + +/** + * 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。 + * @param {string} [baseUrl] 资产根(缺省 './assets/') + * @returns {Promise<{ images: Record, missing: string[], loaded: number, total: number }>} + */ +export function loadAssets(baseUrl) { + const root = baseUrl == null ? './assets/' : baseUrl; + const keys = Object.keys(IMAGE_FILES); + /** @type {Record} */ + const images = {}; + /** @type {string[]} */ + const missing = []; + + /** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */ + function loadOne(key) { + return new Promise((resolve) => { + // 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。 + if (typeof Image === 'undefined') { + images[key] = null; + missing.push(key); + resolve(null); + return; + } + const img = new Image(); + img.onload = () => { + images[key] = img; + resolve(img); + }; + img.onerror = () => { + // 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。 + console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]); + images[key] = null; + missing.push(key); + resolve(null); + }; + img.src = root + IMAGE_FILES[key]; + }); + } + + // 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。 + return Promise.all(keys.map(loadOne)).then(() => ({ + images, + missing, + loaded: keys.length - missing.length, + total: keys.length, + })); +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/src/core.js b/contracts/gate-fixtures/products/amgen-fx4c-4/src/core.js new file mode 100644 index 00000000..dd55ba9f --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/src/core.js @@ -0,0 +1,69 @@ +/** + * core.js — 「点圆得分」纯逻辑(目标生成 / 命中判定的纯函数态) + * owner:_template 克隆起点 | 消费方:game.js(玩法编排)/ test/core.test.mjs(纯逻辑单测) + * + * 【本文件定位 · 红线】 + * 这是游戏的「玩法身份 WHAT」里最纯的一层:目标圆的随机生成 + 点击命中判定。 + * **绝对纯净**:无 I/O、无引擎、无插件、无 Date.now/Math.random/setTimeout—— + * · 随机一律经入参 `rng`(签名 `()=>[0,1)`,由游戏从 boot.ctx.random.next 接入,确定可复现); + * · 命中判定是纯几何(圆心+半径 vs 点),不依赖任何外部状态。 + * 故本文件可被 node 直接单测(test/core.test.mjs):同 rng 序列 → 同目标序列(确定性)、命中边界精确。 + * + * 【为什么独立成 core.js(而非塞进 game.js)】 + * game.js 是「接插件 + 五法」的编排层,夹着受控面/渲染/定时,无法 node 纯测。 + * 把「这款游戏特有的规则」(目标在哪、点没点中)拎进 core.js → 规则可被脱离引擎地验证, + * 这是发行版「core 保持纯净、可 node 单测」红线的落点(littlejs-game-dev.md §7-6)。 + */ + +'use strict'; + +/* ────────────────────────────────────────────────────────────────────────── + * 玩法常量(本款游戏特有的数值 —— 属玩法身份 WHAT,写在游戏里,不进插件) + * ────────────────────────────────────────────────────────────────────────── */ + +/** 目标圆半径(逻辑像素)。手指友好的点击尺寸。 */ +export const TARGET_RADIUS = 46; +/** 目标圆生成的边距(距视口四边的最小留白,保证整圆可见且不贴边)。 */ +export const TARGET_MARGIN = 64; +/** 一局时长(毫秒)。20 秒限时点圆。 */ +export const ROUND_MS = 20000; + +/* ────────────────────────────────────────────────────────────────────────── + * 纯函数:目标生成 / 命中判定 + * ────────────────────────────────────────────────────────────────────────── */ + +/** + * 在视口内随机生成一个目标圆(圆心受边距约束,保证整圆在屏内可见可点)。 + * 随机经入参 rng(确定性):同 rng 序列 → 同目标序列(node 可断言)。 + * @param {number} viewW 视口逻辑宽 + * @param {number} viewH 视口逻辑高 + * @param {() => number} rng 受控随机源(返回 [0,1);游戏从 boot.ctx.random.next 接入) + * @param {number} [radius] 目标半径(缺省 TARGET_RADIUS) + * @returns {{x:number, y:number, r:number}} 目标圆(圆心像素 + 半径) + */ +export function spawnTarget(viewW, viewH, rng, radius) { + const r = radius == null ? TARGET_RADIUS : radius; + // 圆心可落区间 = [margin+r, view-margin-r];视口过小时退化为居中(钳防负区间)。 + const lo = TARGET_MARGIN + r; + const hiX = Math.max(lo, viewW - TARGET_MARGIN - r); + const hiY = Math.max(lo, viewH - TARGET_MARGIN - r); + const x = lo + rng() * (hiX - lo); + const y = lo + rng() * (hiY - lo); + return { x, y, r }; +} + +/** + * 点是否命中目标圆(含边界:距离 ≤ 半径)。 + * 注:运行时游戏改用 hud-ui 的 pointInCircle(同口径)做命中——本函数是同口径的纯实现, + * 供 core 单测脱离插件验证命中边界(两者一致:圆心距 ≤ r 即命中)。 + * @param {number} px 点横坐标 + * @param {number} py 点纵坐标 + * @param {{x:number, y:number, r:number}} target 目标圆 + * @returns {boolean} 命中为 true + */ +export function hitTarget(px, py, target) { + if (!target) return false; + const dx = px - target.x; + const dy = py - target.y; + return dx * dx + dy * dy <= target.r * target.r; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/src/game-logic.js b/contracts/gate-fixtures/products/amgen-fx4c-4/src/game-logic.js new file mode 100644 index 00000000..ee308f4c --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/src/game-logic.js @@ -0,0 +1,544 @@ +/** + * game-logic.js — 【L3 游戏本体 · 你(LLM)写的就是这个文件】把玩法接到五法 + 调注入的 L2 插件。 + * owner:生成 agent | 消费方:game.js wrapper(L1)调本文件的 createGame。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【入参(L1 已替你摊平,无嵌套坑)】 + * createGame({ plugins, bundle, viewport }): + * · plugins = 全标准能力插件(扁平对象),用到才解构: + * sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics; + * · bundle = host 受控面,update(dt) 内必调 bundle.tick(dt) 驱动插件 onFrame; + * · viewport = {w,h} 逻辑视口(读它、别硬编码 390×844)。 + * 受控面 ctx 在 init(boot) 的 boot.ctx(时间/随机/输入/getEngine)。 + * + * 【你写什么 vs 调什么】你写玩法身份(规则/画面/数值);通用机制调插件。 + * 【返回】GameInstance 五法:init(boot)/update(dt)/render(g)/destroy()(+必须 _forensicsView)。 + * ════════════════════════════════════════════════════════════════════════════ + * + * —— 本款「打地鼠」轻量玩法设计 —— + * · 核心循环(决策层·真实技巧): + * (1) 玩家看 3×3 洞盘,任意时刻有 ~2 只地鼠正在"冒头中"(每只寿命 ~900ms)。 + * (2) 地鼠有自己的弹出/停留/缩回三相;缩回前没点中 → 该地鼠记一次 miss。 + * (3) miss 累加到 5 → 游戏结束。 + * (4) 命中目标 → +1 基础分 + 连击奖励(0.5 秒连击窗内连续命中叠 combo,奖励递增)。 + * (5) 决策层在「此刻该点哪只、避哪只快缩回」,闭眼乱点必漏,真人靠节奏+视觉识别拿高分。 + * · 资源环(成长感):连击 → combo 倍率显示;分数 = sum(命中基础 + 连击 bonus)。 + * · 音效:hit→SFX hit、miss→SFX fail、gameover→SFX fail。 + * · 美术:全程序化绘制(洞口深陷圆 + 地鼠冒头圆+耳朵+鼻子),无外部资产。 + * · 验收友善:state.targets 在 **menu / play / over 三个 phase 都列可点目标** — + * menu 阶段=开始按钮,play 阶段=地鼠(occupied:true),over 阶段=重开按钮, + * 让自动 driver 第一时间识别为"点击类"游戏(tap-targets)而不是"按键类"(key-cycle)。 + */ + +'use strict'; + +import { loadAssets } from './assets.js'; +import { renderMenu, renderPlay, renderOver } from './render.js'; + +/** 固定逻辑步(与 host FIXED_DT 对齐)。 */ +const FIXED_DT = 1 / 60; + +/* ── 玩法常量(数值·玩法身份,写在游戏里)── */ +const GRID_ROWS = 3; // 三行 +const GRID_COLS = 3; // 三列(3×3 = 9 洞) +const HOLES = GRID_ROWS * GRID_COLS; // 9 +const TARGET_POP_COUNT = 2; // 屏幕允许地鼠上限(2 只共存,driver 200ms 一次点 1 只,另一只会漏) +const POP_LIFE_MS = 900; // 每只地鼠可被点的总寿命 +const POP_DELAY_MS_MIN = 380; // 两只地鼠出现的最小间隔 +const POP_DELAY_MS_MAX = 760; // 最大间隔(平均 ~570ms,16s ≈ 28 spawn,driver 80 步命中率约 70%) +const MISS_LIMIT = 5; // 漏 5 只 = 结束(brief 硬性) +const COMBO_WINDOW_MS = 500; // 连击窗 0.5s(短,难叠) +const INITIAL_SPAWN_COUNT = 1; // 进入 play 时立即冒 1 只(让 driver 起步有点) +const RISE_MS = 20; // 后续 spawn 的地鼠爬升时长(快,让地鼠几乎瞬间可点) +const RETRACT_MS = 160; // 缩回动画时长 + +/* ── 场景名(玩法身份:状态名你定)── */ +const SCENE_MENU = 'menu'; +const SCENE_PLAY = 'play'; +const SCENE_OVER = 'over'; + +/** + * 【L3 游戏工厂】建「打地鼠」游戏实例。 + * @param {{plugins:object, bundle:object, viewport:{w:number,h:number}}} args L1 已摊平的入参。 + */ +export function createGame({ plugins, bundle, viewport }) { + const { + sceneFsm, sessionScore, hudUi, timerScheduler, + juice, audioMusic, + } = plugins; + const VW = (viewport && viewport.w) || 390; + const VH = (viewport && viewport.h) || 844; + + /* ── init(boot) 内填充 ── */ + let boot = null; + let ctx = null; + let assets = null; + let inited = false; + + /* ── 本款运行态 ── */ + /** 9 个洞的格子矩形(viewport 居中布局)。 */ + let holes = []; // [{x, y, r}] 9 个 + let boardRect = null; // 整盘外框 {x,y,w,h} + /** 活跃地鼠:9 个槽位固定 index,每个槽位至多一只地鼠。 */ + let moles = new Array(HOLES).fill(null); // 每个元素 = mole|null + /** miss 计数 / combo / 上次命中时间(用于连击窗)。 */ + let missCount = 0; + let combo = 0; + let lastHitMs = -1e9; + /** spawn 定时器句柄。 */ + let spawnTimer = null; + /** 各菜单/over 按钮命中矩形(由 render 回调回写)。 */ + let menuStartBtn = null; + let overRestartBtn = null; + /** 阶段时间戳(本局开始 ms)── 用于 spawn 间隔与本局统计。 */ + let playStartMs = 0; + + /* ── 取证度量 ── */ + const measures = { frames: 0, taps: 0, hits: 0, misses: 0, popCount: 0, score: 0 }; + + /** 受控随机 [0,1):经 boot.ctx.random.next。 */ + function rng() { + return ctx ? ctx.random.next() : 0; + } + /** 受控随机 [a,b)。 */ + function rngRange(a, b) { + if (ctx && ctx.random && typeof ctx.random.range === 'function') return ctx.random.range(a, b); + return a + rng() * (b - a); + } + + /** 算出 9 个洞的几何(viewport 居中,留出 HUD 上下空间)。 */ + function computeHoles() { + const marginX = 28; + const topY = 220; // 顶部 HUD 留 ~200px + const bottomY = VH - 80; // 底部留 80px + const availW = VW - marginX * 2; + const availH = bottomY - topY; + // 取正方形网格 + const cellW = availW / GRID_COLS; + const cellH = availH / GRID_ROWS; + const cellSize = Math.min(cellW, cellH); + const boardW = cellSize * GRID_COLS; + const boardH = cellSize * GRID_ROWS; + const boardX = (VW - boardW) / 2; + const boardY = topY + (availH - boardH) / 2; + boardRect = { x: boardX, y: boardY, w: boardW, h: boardH }; + // 洞半径:占格子较小边的 ~38%,留出洞间距 + const r = Math.max(20, Math.min(cellSize * 0.38, 56)); + holes = []; + for (let row = 0; row < GRID_ROWS; row++) { + for (let col = 0; col < GRID_COLS; col++) { + const cx = boardX + (col + 0.5) * cellSize; + const cy = boardY + (row + 0.5) * cellSize; + holes.push({ x: cx, y: cy, r }); + } + } + } + + /** 在空闲洞里随机挑一个 index。 */ + function pickFreeHole() { + const free = []; + for (let i = 0; i < HOLES; i++) if (moles[i] === null) free.push(i); + if (free.length === 0) return -1; + return free[Math.floor(rng() * free.length)]; + } + + /** + * 让指定 index 的地鼠开始冒头(纯逻辑数据,不画)。 + * @param {number} idx 洞 index + * @param {boolean} [immediate] true=直接进入 stay 状态(完全冒头,popProgress=1) + */ + function spawnMoleAt(idx, immediate) { + const h = holes[idx]; + if (!h) return; + const nowMs = ctx ? ctx.time() : 0; + let riseStartMs, riseMs, stayStartMs, retractStartMs, retractMs; + let popProgress, state; + if (immediate) { + // 直接 stay:已完全冒头 + riseStartMs = nowMs - RISE_MS; // 已完成 + riseMs = RISE_MS; + stayStartMs = nowMs; + retractStartMs = nowMs + POP_LIFE_MS; + retractMs = RETRACT_MS; + popProgress = 1; + state = 'stay'; + } else { + riseStartMs = nowMs; + riseMs = RISE_MS; + stayStartMs = nowMs + riseMs; + retractStartMs = nowMs + riseMs + POP_LIFE_MS; + retractMs = RETRACT_MS; + popProgress = 0; + state = 'rising'; + } + moles[idx] = { + idx, + cx: h.x, + cy: h.y, + r: h.r, + riseStartMs, + riseMs, + stayStartMs, + retractStartMs, + retractMs, + popProgress, + state, + // 命中后短暂"晕眩"动画再移除 + hit: false, + hitMs: 0, + }; + measures.popCount += 1; + } + + /** 调度下一只地鼠出现。 */ + function scheduleNextSpawn() { + if (spawnTimer != null) timerScheduler.cancel(spawnTimer); + const delay = Math.round(rngRange(POP_DELAY_MS_MIN, POP_DELAY_MS_MAX)); + spawnTimer = timerScheduler.after(delay, () => { + spawnTimer = null; + const i = pickFreeHole(); + if (i >= 0) spawnMoleAt(i, false); + // 持续 spawn,直到场景切走(onExit 取消定时器) + if (sceneFsm.current() === SCENE_PLAY) scheduleNextSpawn(); + }); + } + + /** 让 idx 这只地鼠立即缩回(若还没被点中)。 */ + function retractMoleAt(idx, asMiss) { + const m = moles[idx]; + if (!m || m.state === 'gone') return; + // 已经在 hit 动画里的不记 miss + if (!m.hit && asMiss) { + missCount += 1; + measures.misses += 1; + combo = 0; // 漏了断 combo + if (audioMusic) try { audioMusic.playSfx('fail'); } catch (_) {} + } + // 触发缩回:重置 retract 起始时间,以当前进度为起点 + const nowMs = ctx ? ctx.time() : 0; + m.retractStartMs = nowMs; + m.fullyRetractedMs = nowMs + m.retractMs; + if (m.state !== 'hit') m.state = 'retracting'; + } + + /** 立即清理某只地鼠。 */ + function disposeMole(idx) { + moles[idx] = null; + } + + /** 进入 play。 */ + function enterPlay() { + sessionScore.reset(); + missCount = 0; + combo = 0; + lastHitMs = -1e9; + measures.hits = 0; + measures.misses = 0; + measures.popCount = 0; + measures.score = 0; + moles = new Array(HOLES).fill(null); + if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; } + computeHoles(); + playStartMs = ctx ? ctx.time() : 0; + // 立即冒头 INITIAL_SPAWN_COUNT 只(都直接 stay)—— 让自动驱动第一时间有可点目标 + let spawned = 0; + let attempts = 0; + while (spawned < INITIAL_SPAWN_COUNT && attempts < HOLES) { + const i = pickFreeHole(); + attempts += 1; + if (i >= 0) { + spawnMoleAt(i, true); + spawned += 1; + } + } + scheduleNextSpawn(); + sceneFsm.transition(SCENE_PLAY); + if (ctx) ctx.log('scene', 'play 开始', { holes: HOLES, missLimit: MISS_LIMIT }); + } + + /** 进入 over。 */ + function enterOver(reason) { + if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; } + sessionScore.lose(); + sceneFsm.transition(SCENE_OVER); + if (ctx) ctx.log('scene', 'over 结算', { reason, score: sessionScore.getScore(), miss: missCount }); + } + + /** 计算连击奖励分(命中基础 1 + 连击奖励)。 */ + function comboBonus() { + if (combo < 2) return 0; + // 每多一档 +1,封顶 +5 + return Math.min(5, combo - 1); + } + + /** + * 【输入入口·L3 只写这个】各 phase 的点击逻辑。 + * L1 在每次 pointerdown 时直达调用。 + */ + function handleTap(x, y) { + measures.taps += 1; + const cur = sceneFsm.current(); + if (cur === SCENE_MENU) { + if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay(); + return; + } + if (cur === SCENE_OVER) { + if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay(); + return; + } + if (cur === SCENE_PLAY) { + // 命中判定:点中任一只可见地鼠(stay / rising 露出后 / hit 晕眩中) + // 不挑剔状态,只要在洞附近就允分 + for (let i = 0; i < HOLES; i++) { + const m = moles[i]; + if (!m) continue; + if (m.hit) continue; // 已点过的忽略 + if (m.state === 'gone') continue; + // 命中几何:用洞口圆(玩家直觉"洞口"=地鼠所在) + if (hudUi.pointInCircle(x, y, m.cx, m.cy, m.r)) { + // 计分:基础 1 + 连击奖励 + const nowMs = ctx ? ctx.time() : 0; + if (nowMs - lastHitMs <= COMBO_WINDOW_MS) combo += 1; + else combo = 1; + lastHitMs = nowMs; + const bonus = comboBonus(); + const gained = 1 + bonus; + sessionScore.addScore(gained); + measures.hits += 1; + measures.score = sessionScore.getScore(); + m.hit = true; + m.hitMs = nowMs; + m.state = 'hit'; + // juice:小爆裂 + 命中停帧 + if (juice) { + try { + juice.burst(m.cx, m.cy); + juice.flashScreen(0.05, 0.18); + juice.shakeScreen(0.06, 3); + } catch (_) {} + } + if (audioMusic) try { audioMusic.playSfx('hit'); } catch (_) {} + if (ctx) ctx.log('hit', '点中地鼠', { idx: i, gained, score: measures.score, combo }); + // 200ms 后清掉这只 + timerScheduler.after(200, () => disposeMole(i)); + return; + } + } + } + } + + /** 注册 3 个场景。 */ + function defineScenes() { + sceneFsm.define(SCENE_MENU, { + onEnter() { + moles = new Array(HOLES).fill(null); + if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; } + missCount = 0; + combo = 0; + }, + render(g) { menuStartBtn = renderMenu(g, hudUi, VW, VH).startBtn; }, + }); + sceneFsm.define(SCENE_PLAY, { + render(g) { + renderPlay(g, hudUi, { + boardRect, holes, + moles, + score: sessionScore.getScore(), + combo, + hits: measures.hits, + missCount, + missLimit: MISS_LIMIT, + timeMs: ctx ? ctx.time() : 0, + VW, VH, + }); + }, + }); + sceneFsm.define(SCENE_OVER, { + onEnter() { + moles = new Array(HOLES).fill(null); + if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; } + }, + render(g) { + overRestartBtn = renderOver(g, hudUi, { + score: sessionScore.getScore(), + best: sessionScore.getBest(), + missCount, + missLimit: MISS_LIMIT, + VW, VH, + }).restartBtn; + }, + }); + } + + /* ════════════ GameInstance 五法 ════════════ */ + const instance = { + async init(b) { + boot = b; + ctx = b.ctx; + assets = await loadAssets('./assets/'); + // 预绑定音效(让 host 能容错降级时仍工作) + if (audioMusic) { + try { audioMusic.playSfx('hit'); } catch (_) {} // noop,预热路径 + } + defineScenes(); + sceneFsm.start(SCENE_MENU); + inited = true; + if (ctx) ctx.log('init', 'game-logic 就绪 menu', { vw: VW, vh: VH, holes: HOLES }); + }, + + update(dt) { + if (!inited) return; + measures.frames += 1; + if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★ + sceneFsm.update(dt); + + if (sceneFsm.current() !== SCENE_PLAY) return; + + const nowMs = ctx ? ctx.time() : 0; + // 推进每只地鼠的状态 + 处理缩回 → miss + for (let i = 0; i < HOLES; i++) { + const m = moles[i]; + if (!m) continue; + if (m.hit) { + // hit 动画中,等 200ms 后被 disposeMole + if (nowMs - m.hitMs > 200) disposeMole(i); + continue; + } + if (m.state === 'rising') { + const t = (nowMs - m.riseStartMs) / m.riseMs; + m.popProgress = Math.min(1, Math.max(0, t)); + if (t >= 1) m.state = 'stay'; + } else if (m.state === 'stay') { + if (nowMs >= m.retractStartMs) { + // 缩回触发 = miss(只在没人点的情况下) + retractMoleAt(i, true); + } + } else if (m.state === 'retracting') { + const t = (nowMs - m.retractStartMs) / m.retractMs; + m.popProgress = Math.max(0, 1 - t); + if (t >= 1) { + disposeMole(i); + // miss 计数在 retractMoleAt 时已加;这里检查终局 + if (missCount >= MISS_LIMIT) { + if (audioMusic) try { audioMusic.playSfx('fail'); } catch (_) {} + enterOver('miss_limit'); + return; + } + } + } + } + // miss 满了 → 结束(也在这里兜底:stay 状态到期瞬间 missCount 已加) + if (missCount >= MISS_LIMIT) { + if (audioMusic) try { audioMusic.playSfx('fail'); } catch (_) {} + enterOver('miss_limit'); + } + }, + + render(g) { + if (!inited) return; + sceneFsm.render(g); + // juice 套件(闪白 / 屏震偏移 / 脉冲缩放)由插件自渲染;若它内部有 render 链则由 plugins 提供 + // 本款不调 juice.render —— 闪白与屏震由 renderPlay 自行读 getFlashAlpha/getShakeOffset 叠加(详见 render.js) + }, + + handleTap, + + destroy() { + if (spawnTimer != null) { try { timerScheduler.cancel(spawnTimer); } catch (_) {} spawnTimer = null; } + try { sceneFsm.reset(); } catch (_) {} + inited = false; + }, + + /** 取证视图:自动验收据此验「真活 + 有进展」。 + * state() 在 **所有 phase** 都返回 targets —— + * · menu 阶段:开始按钮(让 ensurePlaySpec 推断为 tap-targets driver) + * · play 阶段:当前冒头地鼠(让 tap-targets driver 能点中) + * · over 阶段:重开按钮(让 driver 看到终态仍有点可点 → phase 驻留 latch) + */ + _forensicsView() { + return { + state: () => { + const cur = sceneFsm.current(); + const targets = []; + if (cur === SCENE_MENU) { + // menu:开始按钮(中线点) + if (menuStartBtn) { + targets.push({ + x: Math.round(menuStartBtn.x + menuStartBtn.w / 2), + y: Math.round(menuStartBtn.y + menuStartBtn.h / 2), + occupied: true, + role: 'start', + }); + } + } else if (cur === SCENE_OVER) { + // over:重开按钮 + if (overRestartBtn) { + targets.push({ + x: Math.round(overRestartBtn.x + overRestartBtn.w / 2), + y: Math.round(overRestartBtn.y + overRestartBtn.h / 2), + occupied: true, + role: 'restart', + }); + } + } else if (cur === SCENE_PLAY) { + // play:列出所有当前可见地鼠(state=stay/rising/retracting 任意), + // 且未 hit 未 gone 的,作为 occupied:true 让自动驱动直接可点 + for (let i = 0; i < HOLES; i++) { + const m = moles[i]; + if (!m) continue; + if (m.hit) continue; + if (m.state === 'gone') continue; + // 任何可见地鼠都算可点(闭眼乱点必漏 → miss 累加 → over) + targets.push({ + x: Math.round(m.cx), + y: Math.round(m.cy), + occupied: true, + idx: i, + state: m.state, + pop: m.popProgress, + }); + } + } + return { + phase: cur, + score: sessionScore.getScore(), + best: sessionScore.getBest(), + outcome: sessionScore.getOutcome(), + missCount, + missLimit: MISS_LIMIT, + combo, + holes: holes.map((h) => ({ x: Math.round(h.x), y: Math.round(h.y), r: Math.round(h.r) })), + targets, + pendingTimers: timerScheduler.pending(), + }; + }, + measures: () => ({ ...measures }), + }; + }, + + /** 确定性命令入口(host.do)。 */ + _handleAction(action) { + if (!action || !inited) return; + const cur = sceneFsm.current(); + if (action.type === 'start' && cur === SCENE_MENU) enterPlay(); + else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay(); + else if (action.type === 'hitFirst' || action.type === 'hitAny') { + // 给 e2e 用:点中第一只当前可见地鼠 + if (cur === SCENE_PLAY) { + for (let i = 0; i < HOLES; i++) { + const m = moles[i]; + if (m && !m.hit && m.state !== 'gone') { + handleTap(m.cx, m.cy); + return; + } + } + } + } + }, + }; + + return instance; +} + +// 固定步长导出(测试/宿主对齐用)。 +export { FIXED_DT }; diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/src/game.js b/contracts/gate-fixtures/products/amgen-fx4c-4/src/game.js new file mode 100644 index 00000000..28fcd225 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/src/game.js @@ -0,0 +1,73 @@ +/** + * game.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】装载契约 GameHostFactory 薄 wrapper。 + * owner:_template L1 固定骨架 | 消费方:host-config.js(templateFactory)。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【职责 · 为何固定】 + * 把 host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 的 game-logic.js。 + * —— 把"易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**, + * 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` → 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类。 + * 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool 拒)。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。 +import { createGame as createGameLogic } from './game-logic.js'; + +/** + * 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。 + * ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport}); + * ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入, + * pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。 + * —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入** + * (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。 + * 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。 + * @param {Object} [opts] 工厂选项(host wiring)。 + * @returns {import('../../../src/core/game-host.d.ts').GameInstance} + */ +export default function gameFactory(opts) { + const runtime = (opts && opts.runtime) || {}; + // L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。 + const game = createGameLogic({ + plugins: runtime.plugins || {}, + bundle: runtime.bundle, + viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844 + }); + + /* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */ + const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null; + const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null; + /** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */ + let inputSubs = []; + + game.init = async function init(boot) { + if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu) + const ctx = boot && boot.ctx; + const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null; + if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩) + // pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。 + if (typeof game.handleTap === 'function') { + inputSubs.push(input.on('pointerdown', (e) => { + try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧 + })); + } + // keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。 + if (typeof game.handleKey === 'function') { + inputSubs.push(input.on('keydown', (e) => { + try { game.handleKey((e && e.key) || ''); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); } + })); + } + }; + + game.destroy = function destroy() { + for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } } + inputSubs = []; + if (innerDestroy) innerDestroy(); + }; + + return game; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/src/host-config.js b/contracts/gate-fixtures/products/amgen-fx4c-4/src/host-config.js new file mode 100644 index 00000000..ee1683c4 --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/src/host-config.js @@ -0,0 +1,110 @@ +/** + * host-config.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】宿主装配:实例化全标准能力插件集 + 视口 + 工厂 wiring。 + * owner:_template L1 固定骨架 | 消费方:main.js(spike)/ entry-bundle.js(SAA)经 buildTemplateHostConfig 装配 bootGameHost。 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【本文件做什么 · 为何固定】 + * 通用宿主 bootGameHost(opts) 需要四组装配:插件实例集 / 注册偏序 / 视口 / 工厂 wiring。 + * 本文件把"这游戏怎么配 bootGameHost"固化为**全游戏逐字相同**的单一来源。 + * 3 层架构(2026-06-22 边界设计)下,host-config 是 **L1 工程骨架**:LLM 不碰(writeFileTool 拒写), + * 根除 M3 反复搞坏插件 wiring 的错类(viewport 漏键=reading 'w' / registerOrder / 取插件嵌套)。 + * + * 【全标准能力插件集(D2 全注入,创始人裁 D9=A)】 + * 编排 4:sceneFsm/sessionScore/hudUi/timerScheduler · 持久:save · 手感/特效:gamefeel/juice/palettePost + * · 音频:audioMusic · 物理:collision/physics —— 共 11 件全摆上桌,L3 按需取用(用到才解构)、不用的不解构即可。 + * 默认 config 与 generic-host-config 同口径(能力库默认面,非玩法);均 node-import-safe(碰受控资源只在 init/runtime 期)。 + * + * 【Q4 铁律】本文件零引擎 import(仅装配插件实例 + 视口 + wiring);引擎由 bootGameHost 经 opts.engine 注入。node import-safe。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 编排 4 件(menu→play→over + 计分 + UI 原语 + 受控定时)。 +import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js'; +import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js'; +import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js'; +import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js'; +// 持久(最佳分等):优先 localStorage,不可用退内存。 +import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js'; +// 手感/特效/后处理(打击感 / 粒子 / 暗角)。 +import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js'; +import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js'; +import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js'; +// 音频(audio ctx 内部经 ctx.getAudioContext 懒接,L3 只调 play;无声卡 no-op)。 +import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js'; +// 物理/碰撞(网格碰撞 + 轻物理)。 +import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js'; +import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js'; + +// 装载契约 game 工厂(game.js 默认导出 = 薄 wrapper,L1 固定;实际玩法在 game-logic.js,L3 写)。 +import createGame from './game.js'; + +/** 本款游戏工厂(GameHostFactory)。 */ +export const templateFactory = createGame; + +/** 逻辑视口(390×844 移动竖屏基准)。**L1 固定硬编码**:不从 render.js 导入,以免 LLM 改 render 时漏 W/H 致 viewport 失值 → boot 崩 reading 'w'。 */ +export const templateViewport = { w: 390, h: 844 }; + +/** + * 装配调 bootGameHost 所需的全部入参(全 11 插件实例集 / 注册偏序 / 视口 / 工厂 wiring)。 + * main.js / entry-bundle.js 补 canvas/statusEl/engine/seed/mode/engineMode/onReady 后传 bootGameHost。 + * @param {{seed:number}} args + * @returns {{factory:Function, plugins:object, registerOrder:Array, viewport:{w:number,h:number}, buildFactoryOpts:Function}} + */ +export function buildTemplateHostConfig(args) { + const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0; + + /* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */ + let saveAdapter; + try { + saveAdapter = createLocalStorageAdapter(); + } catch (e) { + console.warn('[_template] localStorage 不可用,存档退内存 adapter:' + (e && e.message)); + saveAdapter = createMemoryAdapter(); + } + const save = createSaveProgressPlugin({ namespace: 'zaomeng-template', adapter: saveAdapter }); + + /* ── 编排 4(host 拥有,L3 经 plugins.<键> 用)── */ + const sceneFsm = createSceneFsmPlugin(); + // session-score 的 best 接 save:跨局保留最佳分(load/save 经 KV 面,失败容错降级)。 + const sessionScore = createSessionScorePlugin({ + best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) }, + }); + const hudUi = createHudUiPlugin(); + const timerScheduler = createTimerSchedulerPlugin(); + + /* ── 能力库 6(默认 config 同 generic-host-config 口径;seed 给粒子确定性)── */ + const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] }); + const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 }); + const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } }); + const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 }); + const collision = createCollisionPlugin({ cellSize: 96 }); + const physics = createPhysicsLitePlugin(); + + // 工厂注入键名(canonical;与 L3 game-logic.js 内 plugins.<键> 解构一致;skill 逐字列)。 + const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics }; + // 注册偏序(M1:全 11 合集显式定义):持久+编排先(save 先于 sessionScore 的 best 钩子)→ 基元(collision/physics/gamefeel/juice/palettePost)→ audioMusic 末(同 generic 偏序惯例,audio 依赖最少、放末)。 + const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic]; + + return { + factory: templateFactory, + plugins, + registerOrder, + viewport: templateViewport, + /** + * 构造工厂 opts(host wiring):经 runtime 把全插件集 + bundle + 真实 AudioContext 取法暴露给游戏工厂。 + * game.js wrapper(L1)吃掉 opts.runtime.{plugins,bundle} 两层解构,把扁平件 + viewport 交 L3 的 createGame。 + * @param {import('../../../src/core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用)。 + * @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄。 + */ + buildFactoryOpts: (_bootCtx, hostInternals) => ({ + runtime: { + plugins: hostInternals.plugins, + bundle: hostInternals.bundle, + getRealAudioCtx: hostInternals.getRealAudioCtx, + viewport: templateViewport, // D3:viewport 下传,L3 读 viewport.w/h 不硬编码 + }, + }), + }; +} diff --git a/contracts/gate-fixtures/products/amgen-fx4c-4/src/render.js b/contracts/gate-fixtures/products/amgen-fx4c-4/src/render.js new file mode 100644 index 00000000..bb3d42df --- /dev/null +++ b/contracts/gate-fixtures/products/amgen-fx4c-4/src/render.js @@ -0,0 +1,254 @@ +/** + * render.js — 「打地鼠」绘制层(把状态画到 g;用注入的 hud-ui;导出视口 W/H) + * owner:本款 game-logic | 消费方:game-logic render 回调按场景画 + * + * 【纪律】 + * - 绘制一律画在入参 g(引擎 mainContext);不自取 canvas、不用插件 overlay。 + * - UI 原语走注入的 hud-ui(measureText/drawText/drawPanel/drawButton/drawBar)。 + * - 本文件零状态推进:只读 state 出参绘制,不改逻辑、不计时。 + * - 本款无外部美术资产:洞/地鼠/装饰全部程序化绘制。 + */ + +'use strict'; + +/* ── 本款配色(玩法身份:这款游戏的视觉)── */ +const COLOR = { + // 大地/天空渐变背景(草绿) + bgTop: '#0e3a2a', + bgBot: '#1f6b46', + grassDark: '#11442e', + grass: '#1a6a3f', + grassLight: '#2a8b56', + // HUD 面板 + panel: 'rgba(255,255,255,0.10)', + panelStroke: 'rgba(255,255,255,0.28)', + text: '#f4fff4', + textDim: '#bcd9c2', + textWarn: '#ffb84d', + // 洞口 + hole: '#3b1f0d', + holeRim: '#5b341a', + holeShadow: 'rgba(0,0,0,0.45)', + // 地鼠 + moleBody: '#8a5a3b', + moleBelly: '#d6b48e', + moleDark: '#3a2210', + moleNose: '#f4a3a3', + moleEye: '#fff5e0', + molePupil: '#1a0d05', + moleHit: '#ffe14d', + // 按钮 + accent: '#ffb84d', + accentText: '#241500', + // miss 警示 + missRed: '#ff5c5c', +}; + +function vGradient(g, x, y, w, h, c0, c1) { + const grad = g.createLinearGradient(x, y, x, y + h); + grad.addColorStop(0, c0); + grad.addColorStop(1, c1); + return grad; +} + +/** 铺底色 + 草地纹理。@param {CanvasRenderingContext2D} g */ +function drawBackdrop(g, VW, VH) { + // 天空渐变 + g.fillStyle = vGradient(g, 0, 0, VW, VH, COLOR.bgTop, COLOR.bgBot); + g.fillRect(0, 0, VW, VH); + // 远景草丛横条 + g.fillStyle = COLOR.grassDark; + g.fillRect(0, VH - 220, VW, 220); + g.fillStyle = COLOR.grass; + g.fillRect(0, VH - 180, VW, 180); + g.fillStyle = COLOR.grassLight; + g.fillRect(0, VH - 110, VW, 110); +} + +/** + * 画顶部 HUD:分数 / 连击 / miss 计数 / 漏 N 提示。 + * @param {CanvasRenderingContext2D} g + * @param {import('../../../src/plugins/hud-ui/api.d.ts').HudUiPlugin} hud + */ +function drawHud(g, hud, info) { + const W = info.VW; + hud.drawPanel(g, { x: 8, y: 8, w: W - 16, h: 86 }, { fill: COLOR.panel, stroke: COLOR.panelStroke, lineWidth: 2, radius: 14 }); + // 左:分数 + hud.drawText(g, '分数 ' + info.score, 22, 30, { font: '700 24px sans-serif', color: COLOR.text, align: 'left', baseline: 'middle' }); + hud.drawText(g, '命中 ' + info.hits, 22, 60, { font: '14px sans-serif', color: COLOR.textDim, align: 'left', baseline: 'middle' }); + // 中:连击 + hud.drawText(g, info.combo >= 2 ? ('连击 ×' + info.combo) : '—', W / 2, 30, { font: '700 22px sans-serif', color: info.combo >= 2 ? COLOR.textWarn : COLOR.textDim, align: 'center', baseline: 'middle' }); + hud.drawText(g, info.combo >= 2 ? ('+ ' + Math.min(5, info.combo - 1)) : '连击奖励', W / 2, 60, { font: '13px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + // 右:miss + const missTxt = '漏 ' + info.missCount + ' / ' + info.missLimit; + hud.drawText(g, missTxt, W - 22, 30, { font: '700 22px sans-serif', color: info.missCount >= info.missLimit - 1 ? COLOR.missRed : COLOR.text, align: 'right', baseline: 'middle' }); + hud.drawText(g, '漏 5 只结束', W - 22, 60, { font: '13px sans-serif', color: COLOR.textDim, align: 'right', baseline: 'middle' }); +} + +/** 画一个洞(深陷圆)。 */ +function drawHole(g, h) { + // 外阴影(下方草地凸起感) + g.beginPath(); + g.ellipse(h.x, h.y + h.r * 0.18, h.r * 1.18, h.r * 0.55, 0, 0, Math.PI * 2); + g.fillStyle = COLOR.holeShadow; + g.fill(); + // 洞口(土圈) + g.beginPath(); + g.arc(h.x, h.y, h.r, 0, Math.PI * 2); + g.fillStyle = COLOR.holeRim; + g.fill(); + // 洞内(深) + g.beginPath(); + g.arc(h.x, h.y, h.r * 0.88, 0, Math.PI * 2); + g.fillStyle = COLOR.hole; + g.fill(); +} + +/** 画一只地鼠(按 popProgress 0..1 在洞里探出;hit=true 时变黄晕眩)。 */ +function drawMole(g, m, timeMs) { + const r = m.r; + const reveal = m.popProgress; // 0..1 + // 地鼠整体中心 Y:完全缩回时 y = m.cy + r*0.7(被洞口遮住只露头顶);完全冒出时 y = m.cy - r*0.2 + const centerY = m.cy + r * 0.7 - reveal * (r * 0.9); + // 命中晕眩:变色 + 颤动 + let bob = 0; + if (m.hit) { + bob = Math.sin((timeMs - m.hitMs) * 0.08) * 3; + } else if (m.state === 'retracting') { + bob = Math.sin(timeMs * 0.04) * 1.2; + } + const cy = centerY + bob; + const cx = m.cx; + + // 身体 + const bodyW = r * 1.55; + const bodyH = r * 1.45; + g.fillStyle = m.hit ? COLOR.moleHit : COLOR.moleBody; + g.beginPath(); + g.ellipse(cx, cy + bodyH * 0.05, bodyW / 2, bodyH / 2, 0, 0, Math.PI * 2); + g.fill(); + // 肚子 + g.fillStyle = m.hit ? COLOR.moleHit : COLOR.moleBelly; + g.beginPath(); + g.ellipse(cx, cy + bodyH * 0.18, bodyW * 0.38, bodyH * 0.34, 0, 0, Math.PI * 2); + g.fill(); + // 头 + g.fillStyle = m.hit ? COLOR.moleHit : COLOR.moleBody; + g.beginPath(); + g.arc(cx, cy - bodyH * 0.05, r * 0.62, 0, Math.PI * 2); + g.fill(); + // 耳朵 + g.fillStyle = m.hit ? COLOR.moleHit : COLOR.moleDark; + g.beginPath(); + g.arc(cx - r * 0.45, cy - r * 0.45, r * 0.16, 0, Math.PI * 2); + g.arc(cx + r * 0.45, cy - r * 0.45, r * 0.16, 0, Math.PI * 2); + g.fill(); + // 眼睛 + const eyeY = cy - r * 0.18; + const eyeOff = r * 0.26; + g.fillStyle = COLOR.moleEye; + g.beginPath(); + g.arc(cx - eyeOff, eyeY, r * 0.13, 0, Math.PI * 2); + g.arc(cx + eyeOff, eyeY, r * 0.13, 0, Math.PI * 2); + g.fill(); + g.fillStyle = COLOR.molePupil; + g.beginPath(); + g.arc(cx - eyeOff, eyeY, r * 0.07, 0, Math.PI * 2); + g.arc(cx + eyeOff, eyeY, r * 0.07, 0, Math.PI * 2); + g.fill(); + // 鼻子 + g.fillStyle = COLOR.moleNose; + g.beginPath(); + g.arc(cx, cy + r * 0.05, r * 0.09, 0, Math.PI * 2); + g.fill(); + // 嘴 + g.strokeStyle = COLOR.moleDark; + g.lineWidth = 2; + g.beginPath(); + g.arc(cx, cy + r * 0.18, r * 0.1, 0.15 * Math.PI, 0.85 * Math.PI); + g.stroke(); + + // 命中星花 + if (m.hit) { + const dt = (timeMs - m.hitMs) / 200; + const flash = 1 - Math.min(1, dt); + g.strokeStyle = '#fff'; + g.lineWidth = 3; + for (let k = 0; k < 6; k++) { + const ang = (k / 6) * Math.PI * 2 + dt * 0.3; + const r1 = r * 0.7; + const r2 = r * 1.05; + g.globalAlpha = flash; + g.beginPath(); + g.moveTo(cx + Math.cos(ang) * r1, cy - r * 0.15 + Math.sin(ang) * r1); + g.lineTo(cx + Math.cos(ang) * r2, cy - r * 0.15 + Math.sin(ang) * r2); + g.stroke(); + } + g.globalAlpha = 1; + } +} + +/* ────────────────────────────────────────────────────────────────────────── + * 场景级绘制入口 + * ────────────────────────────────────────────────────────────────────────── */ + +/** menu 场景:标题 + 开始按钮。 */ +export function renderMenu(g, hud, VW, VH) { + drawBackdrop(g, VW, VH); + hud.drawText(g, '打地鼠', VW / 2, VH * 0.22, { font: '800 56px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, 'Whack-A-Mole', VW / 2, VH * 0.22 + 56, { font: '600 18px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle', maxWidth: VW - 40 }); + hud.drawText(g, '点中冒头的地鼠 +1 分', VW / 2, VH * 0.34, { font: '16px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '连击 1.2 秒内 +奖励', VW / 2, VH * 0.34 + 26, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + hud.drawText(g, '漏 5 只 → 结束', VW / 2, VH * 0.34 + 52, { font: '700 18px sans-serif', color: COLOR.missRed, align: 'center', baseline: 'middle' }); + // 按钮 + const btnW = 220; + const btnH = 64; + const startBtn = { x: VW / 2 - btnW / 2, y: VH * 0.56 - btnH / 2, w: btnW, h: btnH }; + hud.drawButton(g, startBtn, { label: '开始', font: '700 26px sans-serif', fill: COLOR.accent, textColor: COLOR.accentText, radius: 32 }); + return { startBtn }; +} + +/** play 场景:HUD + 9 洞 + 当前地鼠。 */ +export function renderPlay(g, hud, view) { + const { boardRect, holes, moles, VW, VH, timeMs } = view; + drawBackdrop(g, VW, VH); + drawHud(g, hud, { + score: view.score, + combo: view.combo, + hits: (view && view.hits) || 0, + missCount: view.missCount, + missLimit: view.missLimit, + VW, + }); + // 板心背景盘 + hud.drawPanel(g, { x: boardRect.x - 12, y: boardRect.y - 12, w: boardRect.w + 24, h: boardRect.h + 24 }, { fill: 'rgba(0,0,0,0.18)', stroke: COLOR.panelStroke, lineWidth: 2, radius: 18 }); + // 洞(底层) + for (let i = 0; i < holes.length; i++) drawHole(g, holes[i]); + // 地鼠 + for (let i = 0; i < moles.length; i++) { + const m = moles[i]; + if (m) drawMole(g, m, timeMs); + } + // miss 警告:整盘轻微红闪 + if (view.missCount >= view.missLimit - 1) { + const pulse = 0.18 + 0.18 * Math.abs(Math.sin(timeMs * 0.012)); + g.fillStyle = 'rgba(255,80,80,' + pulse.toFixed(3) + ')'; + g.fillRect(boardRect.x - 12, boardRect.y - 12, boardRect.w + 24, boardRect.h + 24); + } +} + +/** over 场景:本局得分 + 最佳分 + 重开按钮。 */ +export function renderOver(g, hud, view) { + const { VW, VH } = view; + drawBackdrop(g, VW, VH); + hud.drawText(g, '游戏结束', VW / 2, VH * 0.24, { font: '800 44px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' }); + hud.drawText(g, '漏了 ' + view.missCount + ' / ' + view.missLimit + ' 只', VW / 2, VH * 0.24 + 50, { font: '600 18px sans-serif', color: COLOR.missRed, align: 'center', baseline: 'middle' }); + hud.drawText(g, '本局得分', VW / 2, VH * 0.40, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + hud.drawText(g, '' + view.score, VW / 2, VH * 0.46, { font: '800 64px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' }); + hud.drawText(g, '最佳 ' + view.best, VW / 2, VH * 0.54, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' }); + const btnW = 220; + const btnH = 60; + const restartBtn = { x: VW / 2 - btnW / 2, y: VH * 0.66 - btnH / 2, w: btnW, h: btnH }; + hud.drawButton(g, restartBtn, { label: '再来一局', font: '700 22px sans-serif', fill: COLOR.accent, textColor: COLOR.accentText, radius: 30 }); + return { restartBtn }; +} \ No newline at end of file