feat(gate-fixtures): 卡 menu 族 fx4c×3 升产物型判例 + 剧情金标正例 p11a-s1 固化入仓

判例库补两类被门证实的证据落位,门判据改动前的全量回归多两组锚:

- 卡 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 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-03 14:01:02 -07:00
parent 96de98b345
commit 64eb7a4eac
33 changed files with 5049 additions and 6 deletions

View File

@ -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": "选择有重量",

View File

@ -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`

View File

@ -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": "010s 点 start 进剧情零阅读上手,1060s 第一次选择即有反馈,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
}
}
}

View File

@ -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<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)本款 IMAGE_FILES 为空 返回空集
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
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,
}));
}

View File

@ -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_checklab_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();
}

View File

@ -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_checklab_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;
}

View File

@ -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';
/**
* 装载契约 GameHostFactoryhost 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;
}

View File

@ -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<object>, 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 不硬编码
},
}),
};
}

View File

@ -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 };
}

View File

@ -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 托管化。

View File

@ -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门 完整真玩取证)",

View File

@ -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/<id>/`,再打包:
```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 的原因。

View File

@ -0,0 +1,12 @@
{
"costRmb": 5.1068,
"rmbGate": "active",
"repairs": 2,
"budgetSoftTripped": false,
"traceSummary": {
"traceId": "fix4xx-1",
"steps": 2765,
"dropped": 0,
"middlewareDropped": 0
}
}

View File

@ -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<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)本款 IMAGE_FILES 为空 返回空集
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
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,
}));
}

View File

@ -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 / rotdt 单位=
* 真实下落速度 = 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;
}

View File

@ -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 };

View File

@ -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';
/**
* 装载契约 GameHostFactoryhost 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;
}

View File

@ -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<object>, 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 不硬编码
},
}),
};
}

View File

@ -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/pathHUD 文字与按钮走 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 };
}

View File

@ -0,0 +1,12 @@
{
"costRmb": 11.2632,
"rmbGate": "active",
"repairs": 1,
"budgetSoftTripped": true,
"traceSummary": {
"traceId": "fix4xx-3",
"steps": 2913,
"dropped": 0,
"middlewareDropped": 0
}
}

View File

@ -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<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)本款 IMAGE_FILES 为空 返回空集
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
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,
}));
}

View File

@ -0,0 +1,148 @@
/**
* core.js 太空躲避纯逻辑陨石生成 / 飞船移动 / 命中判定
* owner生成 agentL3 游戏本体 消费方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) };
}

View File

@ -0,0 +1,431 @@
/**
* game-logic.js L3 游戏本体 · 太空躲避把玩法接到五法 + 调注入的 L2 插件
* owner生成 agent点圆得分模板改造 消费方game.js wrapperL1调本文件的 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 };

View File

@ -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';
/**
* 装载契约 GameHostFactoryhost 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;
}

View File

@ -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<object>, 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 不硬编码
},
}),
};
}

View File

@ -0,0 +1,235 @@
/**
* render.js 太空躲避绘制层(把状态画到 g,用注入的 hud-ui;导出视口 W/H)
* owner生成 agentL3 游戏本体 消费方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 };
}

View File

@ -0,0 +1,12 @@
{
"costRmb": 13.0113,
"rmbGate": "active",
"repairs": 2,
"budgetSoftTripped": true,
"traceSummary": {
"traceId": "fix4xx-4",
"steps": 3202,
"dropped": 0,
"middlewareDropped": 0
}
}

View File

@ -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<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)本款 IMAGE_FILES 为空 返回空集
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
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,
}));
}

View File

@ -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;
}

View File

@ -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)
* · 音效:hitSFX hitmissSFX failgameoverSFX 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 };

View File

@ -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';
/**
* 装载契约 GameHostFactoryhost 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;
}

View File

@ -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<object>, 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 不硬编码
},
}),
};
}

View File

@ -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 };
}