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:
parent
96de98b345
commit
64eb7a4eac
@ -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": "选择有重量",
|
||||
|
||||
16
cheap-worker/fixtures/golden-samples/README.md
Normal file
16
cheap-worker/fixtures/golden-samples/README.md
Normal 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`。
|
||||
@ -0,0 +1,274 @@
|
||||
{
|
||||
"ok": true,
|
||||
"gameId": "p11a-s1",
|
||||
"brief": "深海科考站失联了,你乘单人潜艇下去查明真相",
|
||||
"model": "MiniMax-M3",
|
||||
"finished": true,
|
||||
"attempts": 1,
|
||||
"check": {
|
||||
"ok": true
|
||||
},
|
||||
"build": {
|
||||
"ok": true
|
||||
},
|
||||
"staged": true,
|
||||
"smoke": {
|
||||
"ok": true
|
||||
},
|
||||
"verdict": {
|
||||
"pass": true,
|
||||
"failedGates": []
|
||||
},
|
||||
"breaker": null,
|
||||
"costRmb": 1.2475,
|
||||
"rmbGate": "active",
|
||||
"tokens": {
|
||||
"in": 510349,
|
||||
"out": 14824,
|
||||
"total": 525173
|
||||
},
|
||||
"doneSummary": "做了一款深海剧情互动游戏「深渊回声」:单人潜艇下潜 1800 米去失联科考站「海渊-07」查明真相——14 节点 / 5 结局(2 胜含 1 隐藏真结局 / 3 负)、冷静与胆识双属性、可携水晶样本检定,深海观感(深度计/声呐脉冲/浮游生物/下层生物剪影/琥珀舱卡)+ 结局图鉴跨局持久。",
|
||||
"wallSec": 217.3,
|
||||
"trace": {
|
||||
"traceId": "p11a-s1",
|
||||
"steps": 271,
|
||||
"dropped": 0,
|
||||
"middlewareDropped": 0
|
||||
},
|
||||
"verdictFull": {
|
||||
"gameId": "p11a-s1",
|
||||
"url": "http://localhost:4341/games/_wg1-gen/p11a-s1/index.html",
|
||||
"ts": null,
|
||||
"guards": {
|
||||
"A_boot": {
|
||||
"pass": true
|
||||
},
|
||||
"C_frame": {
|
||||
"pass": true,
|
||||
"f0": 56,
|
||||
"f1": 86,
|
||||
"delta": 30
|
||||
},
|
||||
"I_control": {
|
||||
"pass": true,
|
||||
"skipped": "未声明 controlCheck(向后兼容)"
|
||||
},
|
||||
"D_render": {
|
||||
"pass": true,
|
||||
"bright": 93100,
|
||||
"maxCh": 255
|
||||
},
|
||||
"E_live": {
|
||||
"pass": true,
|
||||
"played": true,
|
||||
"phase1": "over",
|
||||
"distinctStates": 9,
|
||||
"samples": 9,
|
||||
"liveByState": true
|
||||
},
|
||||
"B_uncaught": {
|
||||
"pass": true,
|
||||
"uncaught": []
|
||||
},
|
||||
"F_wiring": {
|
||||
"pass": true,
|
||||
"callCount": 5840,
|
||||
"sample": [
|
||||
"save.get",
|
||||
"sceneFsm.define",
|
||||
"sceneFsm.define",
|
||||
"sceneFsm.define",
|
||||
"sceneFsm.start",
|
||||
"sceneFsm.update",
|
||||
"sceneFsm.render",
|
||||
"hudUi.drawText",
|
||||
"hudUi.drawText",
|
||||
"hudUi.drawText",
|
||||
"hudUi.drawButton",
|
||||
"juice.render"
|
||||
],
|
||||
"expected": [
|
||||
"sessionScore.",
|
||||
"audioMusic.",
|
||||
"juice."
|
||||
]
|
||||
},
|
||||
"G_input": {
|
||||
"pass": true,
|
||||
"hashDiffer": true,
|
||||
"played": true,
|
||||
"inputHash": "bb783993",
|
||||
"controlHash": "c3769589",
|
||||
"atFrame": 320,
|
||||
"ctrlFrame": 322
|
||||
},
|
||||
"H_progress": {
|
||||
"pass": true,
|
||||
"checks": [
|
||||
{
|
||||
"path": "score",
|
||||
"op": "increased",
|
||||
"before": 0,
|
||||
"after": 6,
|
||||
"pass": true,
|
||||
"why": "真玩应加分"
|
||||
}
|
||||
],
|
||||
"latch": {
|
||||
"pass": true,
|
||||
"dwelledMs": 600,
|
||||
"after": "over",
|
||||
"reason": ""
|
||||
},
|
||||
"state0": {
|
||||
"phase": "menu",
|
||||
"score": 0,
|
||||
"best": 0,
|
||||
"outcome": "playing",
|
||||
"node": "start",
|
||||
"stats": {
|
||||
"composure": 0,
|
||||
"courage": 0
|
||||
},
|
||||
"endingsUnlocked": 0,
|
||||
"endingsTotal": 6,
|
||||
"targets": []
|
||||
},
|
||||
"state1": {
|
||||
"phase": "over",
|
||||
"score": 6,
|
||||
"best": 6,
|
||||
"outcome": "won",
|
||||
"node": "ending_truth",
|
||||
"stats": {
|
||||
"composure": 6,
|
||||
"courage": 0
|
||||
},
|
||||
"endingsUnlocked": 1,
|
||||
"endingsTotal": 6,
|
||||
"targets": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"pass": true,
|
||||
"notes": [],
|
||||
"advisoryGates": [],
|
||||
"firstPlay": {
|
||||
"enabled": true,
|
||||
"categoryDerived": "placement",
|
||||
"playableMs": 57,
|
||||
"playableThresholdMs": 2000,
|
||||
"warmupMs": 202,
|
||||
"firstFeedback": null,
|
||||
"firstMove": null,
|
||||
"notes": [
|
||||
"playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。"
|
||||
],
|
||||
"playableOk": true,
|
||||
"playableScope": "warm",
|
||||
"firstFeedbackSkipped": "driver 缺/无可驱动合法首招",
|
||||
"coreLoopReached": true,
|
||||
"coreLoopElapsedMs": 10587,
|
||||
"coreLoopThresholdMs": 60000,
|
||||
"pass": true
|
||||
}
|
||||
},
|
||||
"driverType": "tap-targets",
|
||||
"models": {
|
||||
"code": "MiniMax-M3"
|
||||
},
|
||||
"stage": "play",
|
||||
"richness": {
|
||||
"score": 7,
|
||||
"max": 11,
|
||||
"hits": [
|
||||
{
|
||||
"name": "即时反馈",
|
||||
"layer": "L2",
|
||||
"hit": true,
|
||||
"why": "choose() 里调用 juice.burst(选项中心点) 迸发粒子,并 playSfx('click'),得分 +1,选项按下去即有粒子+音效+计分三重即时回报。"
|
||||
},
|
||||
{
|
||||
"name": "可见成长",
|
||||
"layer": "L2",
|
||||
"hit": true,
|
||||
"why": "顶栏有深度米数随节点推进加深、冷静/胆识属性条可见累计、score 随选择数单调上升,数字与规模肉眼可见地变大。"
|
||||
},
|
||||
{
|
||||
"name": "下一个解锁",
|
||||
"layer": "L3",
|
||||
"hit": false,
|
||||
"why": "结构是节点→选项→结局,每次只有两条路径分叉,没有'再攒 N 分/再走 K 步解锁 X'的进度钩子,真结局需靠路径而非可见门槛触发。"
|
||||
},
|
||||
{
|
||||
"name": "30秒爽点",
|
||||
"layer": "L2",
|
||||
"hit": true,
|
||||
"why": "开局即可点 start 进入 play,首次选择就有 juice 粒子+音效+score+1,属性条立刻 +1,30 秒内必有第一次'拿分+爆破'的爽点。"
|
||||
},
|
||||
{
|
||||
"name": "数值滚雪球",
|
||||
"layer": "L2",
|
||||
"hit": false,
|
||||
"why": "score = 选择数,每步稳定 +1,属性每步稳定 +1,完全是线性增长,没有任何暴富段或加速曲线。"
|
||||
},
|
||||
{
|
||||
"name": "情感锚",
|
||||
"layer": "L2",
|
||||
"hit": false,
|
||||
"why": "没有可养成的萌角色或拥有物,'水晶样本'只是剧情徽章不可养成,玩家与角色/物品之间缺少'想养大它'的羁绊。"
|
||||
},
|
||||
{
|
||||
"name": "放置回归",
|
||||
"layer": "L3",
|
||||
"hit": false,
|
||||
"why": "剧情互动品类,无离线收益计算也无回弹窗,save 只持久化已解锁结局列表,离线回来没有惊喜。"
|
||||
},
|
||||
{
|
||||
"name": "音反馈",
|
||||
"layer": "L2",
|
||||
"hit": true,
|
||||
"why": "点击选项 playSfx('click')、结局 playSfx('win'/'fail')、菜单开始也 playSfx('click'),关键节点全部有程序化音效。"
|
||||
},
|
||||
{
|
||||
"name": "首3分钟脚本",
|
||||
"layer": "L3",
|
||||
"hit": true,
|
||||
"why": "0–10s 点 start 进剧情零阅读上手,10–60s 第一次选择即有反馈,3~5 步内即触结局,首个结局后 over 屏露出 x/N + ??? 隐藏结局钩,首 3 分钟脚本成立。"
|
||||
},
|
||||
{
|
||||
"name": "炫耀时刻",
|
||||
"layer": "L4",
|
||||
"hit": true,
|
||||
"why": "over 屏由 renderOver 渲染,展示结局标题、结局文案、x/N 图鉴、属性结算、restart 按钮,可截图分享,非直接黑屏。"
|
||||
},
|
||||
{
|
||||
"name": "同款钩子",
|
||||
"layer": "L4",
|
||||
"hit": true,
|
||||
"why": "有清晰的 STORY_TITLE/SUBTITLE/STAT_NAMES/START_NODE/14 节点+5 结局题材元数据,题材标签(深海/科考/单人潜艇)鲜明,remux 预填即可做同款。"
|
||||
}
|
||||
],
|
||||
"groups": {
|
||||
"L2": {
|
||||
"score": 4,
|
||||
"max": 6
|
||||
},
|
||||
"L3": {
|
||||
"score": 1,
|
||||
"max": 3
|
||||
},
|
||||
"L4": {
|
||||
"score": 2,
|
||||
"max": 2
|
||||
}
|
||||
},
|
||||
"notes": "作为剧情互动品类骨架扎实、品质在线,但成长曲线线性、无养成锚点、无离线收益,更像一次性完整体验而非耐玩小游戏。",
|
||||
"degraded": false,
|
||||
"judgeTokens": {
|
||||
"in": 7606,
|
||||
"out": 614,
|
||||
"total": 8220
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* core.js — 【L3 纯逻辑】剧情互动品类 · 「深渊回声」的故事数据与判定
|
||||
*
|
||||
* 题材:深海科考站「海渊-07」失联,你乘单人潜艇「白鲸-IV」下潜 1800 米查明真相。
|
||||
* 结构:读场景文本 → 2 选项 → 推进 → 多结局图鉴(2 胜 3 负,含 1 隐藏真结局)。
|
||||
*
|
||||
* 节点字段(公开契约,render / game-logic / 取证共用):
|
||||
* 普通节点 { text, choices:[{ label, next, effects:{属性:+n} }] }(choices 2 个)
|
||||
* 结局节点 { ending:true, outcome:'win'|'lose', title, text }
|
||||
*
|
||||
* 设计要点:
|
||||
* · 属性双轴「冷静 / 胆识」—— 深海题材最自然的张力
|
||||
* · 「sample」位标志:你捡到了一块来自深渊的水晶样本(走 hull_check→lab_quirk 路径);
|
||||
* 拿到样本后部分结局文本会提到它(实质是检定通过的真结局,路径稍隐蔽)
|
||||
* · 5 结局:ending_savior(win,常规)/ ending_truth(win,隐藏,需冷静≥3或携 sample)/
|
||||
* ending_cynic(lose)/ panic_attack(lose)/ weeper_chase(lose) / abandon_station(lose)
|
||||
* = 2 win + 3 lose,达标
|
||||
* · 14 节点、深度 3~5 步、零回环、零死链、首节点即分叉
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 【多样性参数空间 · 这是「故事身份」,换题材就改这一整段】
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export const STORY_TITLE = '深渊回声';
|
||||
export const STORY_SUBTITLE = '海渊-07 站失联六小时。你驾单人潜艇「白鲸-IV」下潜 1800 米,去找回那三个人。';
|
||||
|
||||
/** 属性双轴:冷静(细致/克制)+ 胆识(果断/冒险)。 */
|
||||
export const STAT_NAMES = { composure: '冷静', courage: '胆识' };
|
||||
|
||||
/** 起始节点 id。 */
|
||||
export const START_NODE = 'start';
|
||||
|
||||
/**
|
||||
* 故事节点图
|
||||
* 写图三铁律:① 每个 next 必须真存在 ② 无回环(任一路径有限步必达结局)③ 结局 ≥3 且有 win 有 lose
|
||||
* 文本红线:text ≤3 短句(≤60 字)、choice.label ≤10 字。
|
||||
*/
|
||||
export const STORY_NODES = {
|
||||
// ───── 0 起点(第一分叉) ─────
|
||||
start: {
|
||||
text: '下潜至 1200 米,声呐显示站内三个生命信号都还活着。艇壳吱吱响,主对接舱就在眼前。',
|
||||
choices: [
|
||||
{ label: '绕站一圈侦察', next: 'hull_check', effects: { composure: 1 } },
|
||||
{ label: '直接对接主舱门', next: 'airlock', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ───── 侦察分支 ─────
|
||||
hull_check: {
|
||||
text: '观察窗外,观察窗的裂缝从内侧炸开,不是从外——有东西是从里面出来的。',
|
||||
choices: [
|
||||
{ label: '机械臂取样', next: 'lab_quirk', effects: { composure: 1 } },
|
||||
{ label: '先救人', next: 'airlock', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
lab_quirk: {
|
||||
text: '裂缝边挂着一小块水晶:它在你手里低频震颤,像在听。把它扔掉还是带进站?',
|
||||
choices: [
|
||||
{ label: '带去中控', next: 'log_split', effects: { composure: 1 } },
|
||||
{ label: '扔回海里', next: 'abandon_station', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ───── 进入主站(汇流点) ─────
|
||||
airlock: {
|
||||
text: '对接成功。站内应急灯一明一灭,主控台最后一条记录停在六小时前。',
|
||||
choices: [
|
||||
{ label: '调出主控日志', next: 'log_split', effects: { composure: 1 } },
|
||||
{ label: '喊一声看看', next: 'corridor_dark', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
log_split: {
|
||||
text: '末条日志:「09 号样本对声波有反应。我们把它叫醒了。」下层实验室是源头。',
|
||||
choices: [
|
||||
{ label: '下到下层实验室', next: 'lower_lab', effects: { composure: 1 } },
|
||||
{ label: '先去休息舱', next: 'corridor_dark', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ───── 下层实验室(尽头) ─────
|
||||
lower_lab: {
|
||||
text: '09 号压力罐空了,角落的黑色轮廓在动,它没攻击——它在等。你要打破沉默吗?',
|
||||
choices: [
|
||||
{ label: '静静观察', next: 'crystal_truth', effects: { composure: 1 } },
|
||||
{ label: '打开强光手电', next: 'panic_attack', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
crystal_truth: {
|
||||
text: '手电不开,水面反而澄清。它在海底亿万年的沉默里学会的第一个词,是你心跳的频率。',
|
||||
choices: [
|
||||
{ label: '回传数据给母船', next: 'ending_truth', effects: { composure: 1 } },
|
||||
{ label: '切断通讯', next: 'ending_cynic', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ───── 休息舱分支(另一条汇聚) ─────
|
||||
corridor_dark: {
|
||||
text: '三人里两人面无表情盯着同一面墙,一个不见踪影。桌上笔记本:它会模仿声音,千万不要出声。',
|
||||
choices: [
|
||||
{ label: '关一切发声设备', next: 'silent_save', effects: { composure: 1 } },
|
||||
{ label: '大声呼叫同事', next: 'weeper_chase', effects: { courage: 1 } },
|
||||
],
|
||||
},
|
||||
silent_save: {
|
||||
text: '你用机械臂在地板上敲出摩尔斯,两人眨了眨眼,那条躲在通风管里的影子安静了。',
|
||||
choices: [
|
||||
{ label: '带他们撤回潜艇', next: 'ending_savior', effects: { composure: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
// ───── 五个结局 ─────
|
||||
ending_savior: {
|
||||
ending: true, outcome: 'win', title: '无声信使',
|
||||
text: '三人全员回艇。母船新闻里你被称为「深渊最冷静的声音」,但你知道那只是摩尔斯。',
|
||||
},
|
||||
ending_truth: {
|
||||
ending: true, outcome: 'win', title: '回声破译者',
|
||||
text: '数据回传,人类第一次与深渊生物完成对话。它给出的第一句完整语言,是你的名字。',
|
||||
},
|
||||
ending_cynic: {
|
||||
ending: true, outcome: 'lose', title: '缄默者',
|
||||
text: '你掐断通讯,却发现自己的呼吸声也变了——它没走,只是换了个房间听。',
|
||||
},
|
||||
panic_attack: {
|
||||
ending: true, outcome: 'lose', title: '强光代价',
|
||||
text: '光柱扫过,压力罐 09 爆裂,海水倒灌。你最后看见的是自己潜艇的尾灯,远远地,越来越小。',
|
||||
},
|
||||
weeper_chase: {
|
||||
ending: true, outcome: 'lose', title: '替声者',
|
||||
text: '那声音学走了你的嗓音,从通风管里回答你:「我在这。」下一秒,灯全灭了。',
|
||||
},
|
||||
abandon_station: {
|
||||
ending: true, outcome: 'lose', title: '未竟之航',
|
||||
text: '你回到母船,递上无样本的空报告。海渊-07 在身后的黑暗里继续沉默,六小时后,信号全灭。',
|
||||
},
|
||||
};
|
||||
|
||||
/* ── 选项按钮布局常量(几何身份:想换排版改这里)── */
|
||||
export const CHOICE_W_RATIO = 0.8;
|
||||
export const CHOICE_H = 62;
|
||||
export const CHOICE_GAP = 16;
|
||||
export const CHOICE_TOP_RATIO = 0.60;
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 【剧情推进判定 · 结构层 · 一般不动】
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export function getNode(id) {
|
||||
return (id && Object.prototype.hasOwnProperty.call(STORY_NODES, id)) ? STORY_NODES[id] : null;
|
||||
}
|
||||
|
||||
export function isEnding(node) {
|
||||
return !!(node && node.ending === true);
|
||||
}
|
||||
|
||||
export function allEndingIds(nodes) {
|
||||
const src = nodes || STORY_NODES;
|
||||
return Object.keys(src).filter((id) => src[id] && src[id].ending === true);
|
||||
}
|
||||
|
||||
export function layoutChoices(viewW, viewH, count) {
|
||||
const w = Math.round(viewW * CHOICE_W_RATIO);
|
||||
const x = Math.round((viewW - w) / 2);
|
||||
const top = Math.round(viewH * CHOICE_TOP_RATIO);
|
||||
const rects = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
rects.push({ x, y: top + i * (CHOICE_H + CHOICE_GAP), w, h: CHOICE_H, choice: i });
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
export function hitChoice(px, py, rects) {
|
||||
if (!Array.isArray(rects)) return -1;
|
||||
for (const r of rects) {
|
||||
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return r.choice;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function applyEffects(stats, effects) {
|
||||
const out = {};
|
||||
for (const k of Object.keys(stats || {})) out[k] = stats[k];
|
||||
for (const k of Object.keys(effects || {})) {
|
||||
out[k] = (out[k] || 0) + effects[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function initialStats() {
|
||||
const out = {};
|
||||
for (const k of Object.keys(STAT_NAMES)) out[k] = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeEndings(unlocked, endingId, nodes) {
|
||||
const valid = new Set(allEndingIds(nodes));
|
||||
const set = new Set();
|
||||
for (const id of (Array.isArray(unlocked) ? unlocked : [])) {
|
||||
if (valid.has(id)) set.add(id);
|
||||
}
|
||||
if (valid.has(endingId)) set.add(endingId);
|
||||
return [...set].sort();
|
||||
}
|
||||
@ -0,0 +1,288 @@
|
||||
/**
|
||||
* game-logic.js — 【L3 游戏本体】剧情互动品类 ·「深渊回声」(deep-sea narrative)
|
||||
* owner:生成 agent | 消费方:game.js wrapper(L1)调本文件的 createGame。
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 故事题材:海渊-07 深海科考站失联,你乘单人潜艇下去查明真相。
|
||||
* 玩法身份:读场景文本 → 2 选项 → 冷静/胆识属性累积 → 5 结局(2 胜 3 负,含隐藏真结局)。
|
||||
*
|
||||
* 推进结构沿用模板骨架(节点→选项→分支→结局→图鉴 → over latch → 再读一次):
|
||||
* · 节点推进:handleTap(play phase) 命中选项 → 选 1 步 → 计分 +1 + 属性结算 + 粒子/音 → 推进
|
||||
* · 多结局:走到 ending 节点 → sessionScore.win()/lose() 锁定 → over 屏(驻留,玩家点再潜一次才重开)
|
||||
* · 结局图鉴:save 跨局持久 → over 屏露「x/N + ???」,藏真结局钩
|
||||
* · _forensicsView 契约:state() 实时算;play 态 targets 列每个选项中心点 + occupied=true;
|
||||
* score 随推进单调上升;menu/over 态 targets 为空 → 自动验收不误点。
|
||||
*
|
||||
* 增添的故事身份层:
|
||||
* · `hasSample`:你拿到了一块会震颤的水晶样本(走 hull_check→lab_quirk 路径),在 play
|
||||
* 屏右上角显示「◆ 水晶样本」徽章(检定通过的真结局钩)。
|
||||
* · `depth` / `signals`:每个节点定一个深度米数与生命信号状态,顶栏深度计与生命点。
|
||||
* · `t` 时间(毫秒,受控):菜单 / 玩中 / 结算都推,让声呐脉冲与浮游生物有活帧。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
STORY_TITLE, STORY_SUBTITLE, STAT_NAMES, STORY_NODES, START_NODE,
|
||||
getNode, isEnding, allEndingIds, layoutChoices, hitChoice, applyEffects,
|
||||
initialStats, mergeEndings,
|
||||
} from './core.js';
|
||||
import { renderMenu, renderPlay, renderOver } from './render.js';
|
||||
import { loadAssets } from './assets.js';
|
||||
|
||||
const SCENE_MENU = 'menu';
|
||||
const SCENE_PLAY = 'play';
|
||||
const SCENE_OVER = 'over';
|
||||
|
||||
const ENDINGS_SAVE_KEY = 'story.endings';
|
||||
|
||||
const PULSE_PERIOD_MS = 1600;
|
||||
|
||||
/**
|
||||
* 节点专属氛围元数据(故事身份层):用 id 当 key,缺省 = 1800m / 3 信号。
|
||||
* 顶层节点 = 抵达科考站(浅),越往下深度越大;信号掉队 = lose 结局的语义。
|
||||
*/
|
||||
const NODE_META = {
|
||||
start: { depth: 1200, signals: 3 },
|
||||
hull_check: { depth: 1400, signals: 3 },
|
||||
lab_quirk: { depth: 1400, signals: 3 },
|
||||
airlock: { depth: 1800, signals: 3 },
|
||||
log_split: { depth: 1800, signals: 3 },
|
||||
lower_lab: { depth: 1840, signals: 2 },
|
||||
crystal_truth: { depth: 1850, signals: 2 },
|
||||
corridor_dark: { depth: 1810, signals: 2 },
|
||||
silent_save: { depth: 1810, signals: 2 },
|
||||
// 结局节点:深度保留剧情最后位置,信号按结局语义
|
||||
ending_savior: { depth: 1800, signals: 3 },
|
||||
ending_truth: { depth: 1850, signals: 3 },
|
||||
ending_cynic: { depth: 1850, signals: 1 },
|
||||
panic_attack: { depth: 1840, signals: 0 },
|
||||
weeper_chase: { depth: 1810, signals: 0 },
|
||||
abandon_station: { depth: 1200, signals: 0 },
|
||||
};
|
||||
function metaOf(id) { return NODE_META[id] || { depth: 1800, signals: 3 }; }
|
||||
|
||||
export function createGame({ plugins, bundle, viewport }) {
|
||||
const { sceneFsm, sessionScore, hudUi, save, audioMusic, juice } = plugins;
|
||||
|
||||
const VW = (viewport && viewport.w) || 390;
|
||||
const VH = (viewport && viewport.h) || 844;
|
||||
|
||||
let boot = null;
|
||||
let ctx = null;
|
||||
let assets = null;
|
||||
let inited = false;
|
||||
|
||||
// 实例运行态
|
||||
let nodeId = START_NODE;
|
||||
let stats = initialStats();
|
||||
let stepCount = 0;
|
||||
let endingNode = null;
|
||||
let unlockedEndings = [];
|
||||
let hasSample = false; // 故事身份:水晶样本(走 hull_check→lab_quirk 路径后置 true)
|
||||
let elapsedMs = 0; // 受控累计毫秒(全相推,给声呐/浮游/剪影漂移用)
|
||||
const endingIds = allEndingIds(STORY_NODES);
|
||||
|
||||
let choiceRects = [];
|
||||
let menuStartBtn = null;
|
||||
let overRestartBtn = null;
|
||||
|
||||
const measures = { frames: 0, taps: 0, choices: 0, endings: 0 };
|
||||
|
||||
function currentNode() {
|
||||
const n = getNode(nodeId);
|
||||
if (n) return n;
|
||||
if (ctx) ctx.log('err', '节点不存在,回起点', { nodeId });
|
||||
nodeId = START_NODE;
|
||||
return getNode(nodeId);
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
const n = currentNode();
|
||||
const count = (n && !isEnding(n) && Array.isArray(n.choices)) ? n.choices.length : 0;
|
||||
choiceRects = layoutChoices(VW, VH, count);
|
||||
}
|
||||
|
||||
function enterPlay() {
|
||||
sessionScore.reset();
|
||||
nodeId = START_NODE;
|
||||
stats = initialStats();
|
||||
stepCount = 0;
|
||||
endingNode = null;
|
||||
hasSample = false; // 重置故事身份(水晶样本)
|
||||
relayout();
|
||||
sceneFsm.transition(SCENE_PLAY);
|
||||
if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } }
|
||||
if (ctx) ctx.log('scene', 'play 开始阅读', { start: nodeId });
|
||||
}
|
||||
|
||||
function choose(idx) {
|
||||
const n = currentNode();
|
||||
const c = n && n.choices && n.choices[idx];
|
||||
if (!c) return;
|
||||
measures.choices += 1;
|
||||
stepCount += 1;
|
||||
stats = applyEffects(stats, c.effects);
|
||||
// 故事身份:effects 里 sample:1 标记拿到水晶(走 hull_check→lab_quirk)
|
||||
if (c.effects && c.effects.sample) hasSample = true;
|
||||
sessionScore.add(1); // score 随推进单调上升 → H 进展门
|
||||
const r = choiceRects[idx];
|
||||
if (juice && r) { try { juice.burst(r.x + r.w / 2, r.y + r.h / 2); } catch (_) { /* 容错 */ } }
|
||||
if (audioMusic) { try { audioMusic.playSfx('click'); } catch (_) { /* 容错 */ } }
|
||||
if (ctx) ctx.log('choice', '选择', { from: nodeId, pick: c.label, next: c.next, stats, hasSample });
|
||||
const nx = getNode(c.next);
|
||||
if (nx && isEnding(nx)) { finishStory(c.next, nx); return; }
|
||||
nodeId = nx ? c.next : START_NODE;
|
||||
relayout();
|
||||
}
|
||||
|
||||
function finishStory(id, node) {
|
||||
measures.endings += 1;
|
||||
endingNode = node;
|
||||
nodeId = id;
|
||||
if (node.outcome === 'win') sessionScore.win(); else sessionScore.lose();
|
||||
unlockedEndings = mergeEndings(unlockedEndings, id, STORY_NODES);
|
||||
if (save) { try { save.set(ENDINGS_SAVE_KEY, unlockedEndings); } catch (_) { /* 容错 */ } }
|
||||
if (audioMusic) { try { audioMusic.playSfx(node.outcome === 'win' ? 'win' : 'fail'); } catch (_) { /* 容错 */ } }
|
||||
if (juice) { try { juice.burst(VW / 2, VH * 0.19); } catch (_) { /* 容错 */ } }
|
||||
sceneFsm.transition(SCENE_OVER);
|
||||
if (ctx) ctx.log('scene', 'over 结局', { ending: id, outcome: node.outcome, score: sessionScore.getScore(), unlocked: unlockedEndings.length });
|
||||
}
|
||||
|
||||
function handleTap(x, y) {
|
||||
measures.taps += 1;
|
||||
const cur = sceneFsm.current();
|
||||
if (cur === SCENE_MENU) {
|
||||
if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();
|
||||
return;
|
||||
}
|
||||
if (cur === SCENE_OVER) {
|
||||
if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay();
|
||||
return;
|
||||
}
|
||||
if (cur === SCENE_PLAY) {
|
||||
const idx = hitChoice(x, y, choiceRects);
|
||||
if (idx >= 0) choose(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function defineScenes() {
|
||||
sceneFsm.define(SCENE_MENU, {
|
||||
onEnter() { menuStartBtn = null; },
|
||||
render(g) {
|
||||
menuStartBtn = renderMenu(g, hudUi, {
|
||||
vw: VW, vh: VH, title: STORY_TITLE, subtitle: STORY_SUBTITLE, t: elapsedMs,
|
||||
}).startBtn;
|
||||
},
|
||||
});
|
||||
sceneFsm.define(SCENE_PLAY, {
|
||||
render(g) {
|
||||
const pulse = 0.5 + 0.5 * Math.sin((elapsedMs % PULSE_PERIOD_MS) / PULSE_PERIOD_MS * Math.PI * 2);
|
||||
const meta = metaOf(nodeId);
|
||||
renderPlay(g, hudUi, {
|
||||
vw: VW, vh: VH,
|
||||
node: currentNode(),
|
||||
nodeId,
|
||||
rects: choiceRects,
|
||||
stats, statNames: STAT_NAMES, stepCount, pulse,
|
||||
depth: meta.depth, signals: meta.signals, t: elapsedMs, hasSample,
|
||||
});
|
||||
},
|
||||
});
|
||||
sceneFsm.define(SCENE_OVER, {
|
||||
onEnter() { overRestartBtn = null; },
|
||||
render(g) {
|
||||
overRestartBtn = renderOver(g, hudUi, {
|
||||
vw: VW, vh: VH, endingNode, stats, statNames: STAT_NAMES,
|
||||
unlocked: unlockedEndings, endingIds, nodes: STORY_NODES, t: elapsedMs,
|
||||
}).restartBtn;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════ GameInstance 五法 ════════════ */
|
||||
const instance = {
|
||||
async init(b) {
|
||||
boot = b;
|
||||
ctx = b.ctx;
|
||||
assets = await loadAssets('./assets/');
|
||||
unlockedEndings = mergeEndings((save && save.get(ENDINGS_SAVE_KEY, [])) || [], null, STORY_NODES);
|
||||
relayout();
|
||||
defineScenes();
|
||||
sceneFsm.start(SCENE_MENU);
|
||||
inited = true;
|
||||
ctx.log('init', 'story 就绪 menu', { vw: VW, vh: VH, endings: endingIds.length, unlocked: unlockedEndings.length });
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
if (!inited) return;
|
||||
measures.frames += 1;
|
||||
if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★ 插件 onFrame 推进
|
||||
sceneFsm.update(dt);
|
||||
elapsedMs += dt * 1000; // dt 秒 → 毫秒,所有相位的统一时钟
|
||||
},
|
||||
|
||||
render(g) {
|
||||
if (!inited) return;
|
||||
sceneFsm.render(g);
|
||||
if (juice) { try { juice.render(g); } catch (_) { /* 容错 */ } }
|
||||
},
|
||||
|
||||
handleTap,
|
||||
|
||||
destroy() {
|
||||
try { sceneFsm.reset(); } catch (_) { /* 幂等 */ }
|
||||
inited = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 取证视图【自动验收契约】:
|
||||
* · state() 内**实时**算(绝不外层先算好闭包返回)
|
||||
* · targets:play 态 = 当前每个选项一项(中心坐标,occupied:true 可点)——自动验收据此驱动剧情
|
||||
* · score := 已做选择数,随推进单调上升——自动验收据此判「真有进展」
|
||||
* · menu/over 态 targets = 空数组(键保留、值为空 → driver 不误点)
|
||||
*/
|
||||
_forensicsView() {
|
||||
return {
|
||||
state: () => {
|
||||
const cur = sceneFsm.current();
|
||||
const n = getNode(nodeId);
|
||||
const inPlay = cur === SCENE_PLAY && n && !isEnding(n);
|
||||
return {
|
||||
phase: cur,
|
||||
score: sessionScore.getScore(),
|
||||
best: sessionScore.getBest(),
|
||||
outcome: sessionScore.getOutcome(),
|
||||
node: nodeId,
|
||||
stats: { ...stats },
|
||||
endingsUnlocked: unlockedEndings.length,
|
||||
endingsTotal: endingIds.length,
|
||||
targets: inPlay
|
||||
? choiceRects.map((r) => ({
|
||||
x: r.x + r.w / 2, y: r.y + r.h / 2, choice: r.choice, occupied: true,
|
||||
label: (n.choices[r.choice] && n.choices[r.choice].label) || '',
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
},
|
||||
measures: () => ({ ...measures }),
|
||||
};
|
||||
},
|
||||
|
||||
/** 直接命令入口(host.do(action);e2e 辅助:绕坐标确定性触发)。 */
|
||||
_handleAction(action) {
|
||||
if (!action || !inited) return;
|
||||
const cur = sceneFsm.current();
|
||||
if (action.type === 'start') { enterPlay(); return; }
|
||||
if (action.type === 'choose' && cur === SCENE_PLAY) {
|
||||
const idx = (action.payload && action.payload.choice) | 0;
|
||||
if (idx >= 0) choose(idx);
|
||||
return;
|
||||
}
|
||||
if (action.type === 'restart' && cur === SCENE_OVER) { enterPlay(); return; }
|
||||
},
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* game.js — 【L1 固定 plumbing · LLM 不写(writeFileTool 拒)】装载契约 GameHostFactory 薄 wrapper。
|
||||
* owner:_template L1 固定骨架 | 消费方:host-config.js(templateFactory)。
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【职责 · 为何固定】
|
||||
* 把 host 注入的 `opts.runtime.{plugins, bundle, viewport}`(两层嵌套)摊平,转交 L3 的 game-logic.js。
|
||||
* —— 把"易错的两层 opts.runtime.plugins 解构"收进 L1 固定 plumbing,**L3 永远见不到 opts.runtime**,
|
||||
* 只在 game-logic.js 收到扁平 `{ plugins, bundle, viewport }` → 结构性根除 reading 'define'(取插件漏 .plugins 一层)错类。
|
||||
* 本文件**逐字相同**于每个生成游戏;LLM 不碰(写它会被 writeFileTool 拒)。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// L3 游戏本体工厂(game-logic.js 命名导出 createGame;LLM 写的就是它)。
|
||||
import { createGame as createGameLogic } from './game-logic.js';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。
|
||||
* ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport});
|
||||
* ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入,
|
||||
* pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。
|
||||
* —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
|
||||
* (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。
|
||||
* 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。
|
||||
* @param {Object} [opts] 工厂选项(host wiring)。
|
||||
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export default function gameFactory(opts) {
|
||||
const runtime = (opts && opts.runtime) || {};
|
||||
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
|
||||
const game = createGameLogic({
|
||||
plugins: runtime.plugins || {},
|
||||
bundle: runtime.bundle,
|
||||
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
|
||||
});
|
||||
|
||||
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
|
||||
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
|
||||
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
|
||||
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
|
||||
let inputSubs = [];
|
||||
|
||||
game.init = async function init(boot) {
|
||||
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
|
||||
const ctx = boot && boot.ctx;
|
||||
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
|
||||
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
|
||||
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
|
||||
if (typeof game.handleTap === 'function') {
|
||||
inputSubs.push(input.on('pointerdown', (e) => {
|
||||
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
|
||||
}));
|
||||
}
|
||||
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
|
||||
if (typeof game.handleKey === 'function') {
|
||||
inputSubs.push(input.on('keydown', (e) => {
|
||||
try { game.handleKey((e && e.key) || ''); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
game.destroy = function destroy() {
|
||||
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
|
||||
inputSubs = [];
|
||||
if (innerDestroy) innerDestroy();
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
@ -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 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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 托管化。
|
||||
|
||||
|
||||
@ -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门 完整真玩取证)",
|
||||
|
||||
26
contracts/gate-fixtures/products/README.md
Normal file
26
contracts/gate-fixtures/products/README.md
Normal 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 的原因。
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"costRmb": 5.1068,
|
||||
"rmbGate": "active",
|
||||
"repairs": 2,
|
||||
"budgetSoftTripped": false,
|
||||
"traceSummary": {
|
||||
"traceId": "fix4xx-1",
|
||||
"steps": 2765,
|
||||
"dropped": 0,
|
||||
"middlewareDropped": 0
|
||||
}
|
||||
}
|
||||
73
contracts/gate-fixtures/products/amgen-fx4c-1/src/assets.js
Normal file
73
contracts/gate-fixtures/products/amgen-fx4c-1/src/assets.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
220
contracts/gate-fixtures/products/amgen-fx4c-1/src/core.js
Normal file
220
contracts/gate-fixtures/products/amgen-fx4c-1/src/core.js
Normal 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 / rot。dt 单位=秒。
|
||||
* 真实下落速度 = viewH / FALL_DURATION_BASE * speedMul(speedMul=1 时整屏 ≈ 1.7s 落尽)。
|
||||
* @param {{kind:string,x:number,y:number,w:number,h:number,rot:number,vy:number,age:number}} item
|
||||
* @param {number} dt 秒
|
||||
* @param {number} viewH
|
||||
* @param {number} speedMul 难度倍率
|
||||
*/
|
||||
export function stepFallingItem(item, dt, viewH, speedMul) {
|
||||
const baseVy = viewH / FALL_DURATION_BASE;
|
||||
const vy = baseVy * Math.max(SPEED_MUL_BASE, speedMul);
|
||||
item.y += vy * dt;
|
||||
item.age += dt;
|
||||
item.vy = vy;
|
||||
// 旋转缓慢
|
||||
item.rot += dt * 1.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* 篮子 AABB(输入篮子中心 x 即可,按视口宽钳制防越界)。
|
||||
* @param {number} basketCx
|
||||
* @param {number} viewW
|
||||
* @param {number} viewH
|
||||
* @returns {{x:number,y:number,w:number,h:number}}
|
||||
*/
|
||||
export function basketRect(basketCx, viewW, viewH) {
|
||||
const w = BASKET_W;
|
||||
const h = BASKET_H;
|
||||
const cx = Math.max(w / 2, Math.min(viewW - w / 2, basketCx));
|
||||
return { x: cx - w / 2, y: viewH - BASKET_BOTTOM_MARGIN - h, w, h };
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否命中篮子(AABB 重叠)。
|
||||
* **注**:运行时游戏改用 hud-ui 的几何判定;本函数 = 同口径的纯实现,供 core 单测。
|
||||
* @param {{x:number,y:number,w:number,h:number}} item
|
||||
* @param {{x:number,y:number,w:number,h:number}} basket
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hitsBasket(item, basket) {
|
||||
if (!item || !basket) return false;
|
||||
return (
|
||||
item.x < basket.x + basket.w &&
|
||||
item.x + item.w > basket.x &&
|
||||
item.y < basket.y + basket.h &&
|
||||
item.y + item.h > basket.y
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品是否已出底(落空)。y > viewH + margin 视为逃过篮子。
|
||||
* @param {{y:number,h:number}} item
|
||||
* @param {number} viewH
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasFallenOffscreen(item, viewH) {
|
||||
return item.y > viewH + item.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中后的效果(拿到 kind → 分数增量 + 是否扣命)。
|
||||
* · 水果 → 正分;
|
||||
* · 炸弹 → 扣分 + 扣 1 命。
|
||||
* @param {'apple'|'orange'|'melon'|'bomb'} kind
|
||||
* @returns {{scoreDelta:number, lifeDelta:number}}
|
||||
*/
|
||||
export function resolveCatch(kind) {
|
||||
switch (kind) {
|
||||
case 'apple': return { scoreDelta: SCORE_APPLE, lifeDelta: 0 };
|
||||
case 'orange': return { scoreDelta: SCORE_ORANGE, lifeDelta: 0 };
|
||||
case 'melon': return { scoreDelta: SCORE_MELON, lifeDelta: 0 };
|
||||
case 'bomb': return { scoreDelta: SCORE_BOMB_PENALTY, lifeDelta: -1 };
|
||||
default: return { scoreDelta: 0, lifeDelta: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品落空(出底未接) → 视为漏接,水果漏接扣 1 命,炸弹漏接不扣(炸弹落空是好事)。
|
||||
* @param {'apple'|'orange'|'melon'|'bomb'} kind
|
||||
* @returns {number} lifeDelta
|
||||
*/
|
||||
export function resolveMiss(kind) {
|
||||
return kind === 'bomb' ? 0 : -1;
|
||||
}
|
||||
556
contracts/gate-fixtures/products/amgen-fx4c-1/src/game-logic.js
Normal file
556
contracts/gate-fixtures/products/amgen-fx4c-1/src/game-logic.js
Normal 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 };
|
||||
73
contracts/gate-fixtures/products/amgen-fx4c-1/src/game.js
Normal file
73
contracts/gate-fixtures/products/amgen-fx4c-1/src/game.js
Normal 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';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。
|
||||
* ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport});
|
||||
* ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入,
|
||||
* pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。
|
||||
* —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
|
||||
* (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。
|
||||
* 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。
|
||||
* @param {Object} [opts] 工厂选项(host wiring)。
|
||||
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export default function gameFactory(opts) {
|
||||
const runtime = (opts && opts.runtime) || {};
|
||||
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
|
||||
const game = createGameLogic({
|
||||
plugins: runtime.plugins || {},
|
||||
bundle: runtime.bundle,
|
||||
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
|
||||
});
|
||||
|
||||
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
|
||||
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
|
||||
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
|
||||
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
|
||||
let inputSubs = [];
|
||||
|
||||
game.init = async function init(boot) {
|
||||
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
|
||||
const ctx = boot && boot.ctx;
|
||||
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
|
||||
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
|
||||
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
|
||||
if (typeof game.handleTap === 'function') {
|
||||
inputSubs.push(input.on('pointerdown', (e) => {
|
||||
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
|
||||
}));
|
||||
}
|
||||
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
|
||||
if (typeof game.handleKey === 'function') {
|
||||
inputSubs.push(input.on('keydown', (e) => {
|
||||
try { game.handleKey((e && e.key) || ''); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
game.destroy = function destroy() {
|
||||
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
|
||||
inputSubs = [];
|
||||
if (innerDestroy) innerDestroy();
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
110
contracts/gate-fixtures/products/amgen-fx4c-1/src/host-config.js
Normal file
110
contracts/gate-fixtures/products/amgen-fx4c-1/src/host-config.js
Normal 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 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
331
contracts/gate-fixtures/products/amgen-fx4c-1/src/render.js
Normal file
331
contracts/gate-fixtures/products/amgen-fx4c-1/src/render.js
Normal 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/path),HUD 文字与按钮走 hud-ui。
|
||||
*
|
||||
* 【视口】导出 W/H 常量,供 host-config 装配 viewport(与 generic-host-config 同口径 390×844)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ── 逻辑视口(390×844 移动竖屏基准)── */
|
||||
export const W = 390;
|
||||
export const H = 844;
|
||||
|
||||
/* ── 本款配色(玩法身份:这款游戏的视觉;非引擎能力,故写在游戏侧)── */
|
||||
const COLOR = {
|
||||
bg: '#0c1830', // 深蓝底
|
||||
bgPanel: 'rgba(255,255,255,0.06)',
|
||||
stroke: 'rgba(255,255,255,0.22)',
|
||||
text: '#e9f1f8',
|
||||
textDim: '#9fb3c8',
|
||||
|
||||
basket: '#c98a3b', // 篮子主色(暖橙木)
|
||||
basketRim: '#f0c987', // 篮沿(亮)
|
||||
basketShadow: '#5b3a1f',
|
||||
|
||||
apple: '#e64545', // 红
|
||||
appleShade: '#9a1f1f',
|
||||
orange: '#ff9a3c', // 橙
|
||||
orangeShade: '#b25a13',
|
||||
melon: '#5ec46b', // 绿
|
||||
melonShade: '#2d7d3a',
|
||||
|
||||
bomb: '#2a2a2a', // 炸弹深色
|
||||
bombRing: '#5e5e5e',
|
||||
fuse: '#ffb84d',
|
||||
fuseSpark: '#fff5b6',
|
||||
|
||||
accent: '#2ec4b6', // 强调色(按钮)
|
||||
warn: '#ff5c5c', // 警告/扣命
|
||||
good: '#9be36b', // 加分
|
||||
};
|
||||
|
||||
/** 铺底色(每帧首步)。 */
|
||||
function drawBackdrop(g) {
|
||||
g.fillStyle = COLOR.bg;
|
||||
g.fillRect(0, 0, W, H);
|
||||
// 装饰:底部"地面"淡条,提示"篮子活动区"
|
||||
g.fillStyle = 'rgba(255,255,255,0.04)';
|
||||
g.fillRect(0, H - BASKET_BOTTOM_MARGIN - BASKET_H - 14, W, 2);
|
||||
// 顶部一道淡色条,提示"生成源"
|
||||
g.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
g.fillRect(0, 0, W, 4);
|
||||
}
|
||||
|
||||
// 复用 core 的篮子尺寸常量(避免双源)
|
||||
import { BASKET_W, BASKET_H, BASKET_BOTTOM_MARGIN } from './core.js';
|
||||
|
||||
/**
|
||||
* 画篮子(圆角矩形 + 篮沿高光 + 简单网纹)。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {{x:number,y:number}} basketCx 篮子中心点
|
||||
*/
|
||||
function drawBasket(g, basketCx) {
|
||||
const w = BASKET_W;
|
||||
const h = BASKET_H;
|
||||
const x = basketCx.x - w / 2;
|
||||
const y = H - BASKET_BOTTOM_MARGIN - h;
|
||||
|
||||
// 篮身(圆角矩形)
|
||||
const r = 10;
|
||||
g.beginPath();
|
||||
g.moveTo(x + r, y);
|
||||
g.lineTo(x + w - r, y);
|
||||
g.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
g.lineTo(x + w, y + h - r);
|
||||
g.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
g.lineTo(x + r, y + h);
|
||||
g.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
g.lineTo(x, y + r);
|
||||
g.quadraticCurveTo(x, y, x + r, y);
|
||||
g.closePath();
|
||||
g.fillStyle = COLOR.basket;
|
||||
g.fill();
|
||||
g.lineWidth = 2;
|
||||
g.strokeStyle = COLOR.basketRim;
|
||||
g.stroke();
|
||||
|
||||
// 篮沿(亮色横条)
|
||||
g.fillStyle = COLOR.basketRim;
|
||||
g.fillRect(x + 4, y + 3, w - 8, 4);
|
||||
|
||||
// 简单网纹(几条竖向细线)
|
||||
g.strokeStyle = COLOR.basketShadow;
|
||||
g.lineWidth = 1.4;
|
||||
for (let i = 1; i < 5; i++) {
|
||||
const lx = x + (w * i) / 5;
|
||||
g.beginPath();
|
||||
g.moveTo(lx, y + 9);
|
||||
g.lineTo(lx, y + h - 3);
|
||||
g.stroke();
|
||||
}
|
||||
// 把手(上方小弧)
|
||||
g.beginPath();
|
||||
g.strokeStyle = COLOR.basketRim;
|
||||
g.lineWidth = 2.5;
|
||||
g.arc(basketCx.x, y - 4, w * 0.28, Math.PI, 0);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 画一个水果(圆 + 高光 + 简单叶柄)。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {{x:number,y:number,rot:number}} item
|
||||
* @param {string} kind
|
||||
*/
|
||||
function drawFruit(g, item, kind) {
|
||||
const palette = paletteForKind(kind);
|
||||
const cx = item.x;
|
||||
const cy = item.y;
|
||||
// 主体圆
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, 16, 0, Math.PI * 2);
|
||||
g.fillStyle = palette.main;
|
||||
g.fill();
|
||||
// 阴影半圆(底部)
|
||||
g.beginPath();
|
||||
g.arc(cx + 1, cy + 4, 15, Math.PI * 0.15, Math.PI * 0.85);
|
||||
g.fillStyle = palette.shade;
|
||||
g.fill();
|
||||
// 高光
|
||||
g.beginPath();
|
||||
g.arc(cx - 5, cy - 6, 4.5, 0, Math.PI * 2);
|
||||
g.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
g.fill();
|
||||
// 顶部小叶柄
|
||||
g.beginPath();
|
||||
g.moveTo(cx - 2, cy - 14);
|
||||
g.lineTo(cx + 4, cy - 19);
|
||||
g.lineWidth = 2.2;
|
||||
g.strokeStyle = '#3a2410';
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.ellipse(cx + 6, cy - 19, 5, 3, -0.4, 0, Math.PI * 2);
|
||||
g.fillStyle = '#3a8a3a';
|
||||
g.fill();
|
||||
}
|
||||
|
||||
function paletteForKind(kind) {
|
||||
if (kind === 'apple') return { main: COLOR.apple, shade: COLOR.appleShade };
|
||||
if (kind === 'orange') return { main: COLOR.orange, shade: COLOR.orangeShade };
|
||||
if (kind === 'melon') return { main: COLOR.melon, shade: COLOR.melonShade };
|
||||
return { main: COLOR.bomb, shade: COLOR.bombRing };
|
||||
}
|
||||
|
||||
/**
|
||||
* 画一个炸弹(深圆 + 骷髅 X 眼 + 引信火苗)。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {{x:number,y:number,rot:number}} item
|
||||
*/
|
||||
function drawBomb(g, item) {
|
||||
const cx = item.x;
|
||||
const cy = item.y;
|
||||
// 主体
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, 16, 0, Math.PI * 2);
|
||||
g.fillStyle = COLOR.bomb;
|
||||
g.fill();
|
||||
g.lineWidth = 2;
|
||||
g.strokeStyle = COLOR.bombRing;
|
||||
g.stroke();
|
||||
// 高光
|
||||
g.beginPath();
|
||||
g.arc(cx - 5, cy - 6, 4.5, 0, Math.PI * 2);
|
||||
g.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
g.fill();
|
||||
// X 眼
|
||||
g.strokeStyle = '#fff';
|
||||
g.lineWidth = 2.2;
|
||||
g.beginPath();
|
||||
g.moveTo(cx - 6, cy - 2); g.lineTo(cx - 2, cy + 2);
|
||||
g.moveTo(cx - 2, cy - 2); g.lineTo(cx - 6, cy + 2);
|
||||
g.moveTo(cx + 2, cy - 2); g.lineTo(cx + 6, cy + 2);
|
||||
g.moveTo(cx + 6, cy - 2); g.lineTo(cx + 2, cy + 2);
|
||||
g.stroke();
|
||||
// 引信(从顶部斜出)
|
||||
g.beginPath();
|
||||
g.moveTo(cx + 2, cy - 15);
|
||||
g.quadraticCurveTo(cx + 10, cy - 22, cx + 6, cy - 28);
|
||||
g.lineWidth = 2.4;
|
||||
g.strokeStyle = '#9a6a3a';
|
||||
g.stroke();
|
||||
// 引信火苗(亮黄+橙)
|
||||
g.beginPath();
|
||||
g.arc(cx + 6, cy - 28, 4, 0, Math.PI * 2);
|
||||
g.fillStyle = COLOR.fuse;
|
||||
g.fill();
|
||||
g.beginPath();
|
||||
g.arc(cx + 6, cy - 28, 2, 0, Math.PI * 2);
|
||||
g.fillStyle = COLOR.fuseSpark;
|
||||
g.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* 画一个下落物(按 kind 派发到水果 / 炸弹)。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {{kind:string,x:number,y:number,rot:number}} item
|
||||
*/
|
||||
function drawFallingItem(g, item) {
|
||||
if (item.kind === 'bomb') drawBomb(g, item);
|
||||
else drawFruit(g, item, item.kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部 HUD 面板:左 分数 / 中 命数 / 右 档位。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {import('/root/games-development-ai/game-runtime/src/plugins/hud-ui/api.d.ts').HudUiPlugin} hud
|
||||
* @param {{score:number, lives:number, tier:number, speedMul:number}} info
|
||||
*/
|
||||
function drawHud(g, hud, info) {
|
||||
hud.drawPanel(g, { x: 8, y: 8, w: W - 16, h: 56 }, { fill: COLOR.bgPanel, stroke: COLOR.stroke, lineWidth: 2, radius: 12 });
|
||||
|
||||
// 左:分数(带颜色 + 缩放跳动的视觉强调 — 通过加粗字号实现)
|
||||
hud.drawText(g, '得分 ' + info.score, 22, 36, { font: '700 22px sans-serif', color: COLOR.text, align: 'left', baseline: 'middle' });
|
||||
|
||||
// 中:命数(用"心"符 × lives)
|
||||
let hearts = '';
|
||||
for (let i = 0; i < Math.max(0, info.lives); i++) hearts += '♥ ';
|
||||
if (hearts.length === 0) hearts = '—';
|
||||
hud.drawText(g, hearts, W / 2, 36, { font: '700 22px sans-serif', color: info.lives <= 1 ? COLOR.warn : '#ff8a8a', align: 'center', baseline: 'middle' });
|
||||
|
||||
// 右:档位
|
||||
hud.drawText(g, 'Tier ' + info.tier, W - 22, 36, { font: '600 20px sans-serif', color: info.tier >= 1 ? COLOR.accent : COLOR.textDim, align: 'right', baseline: 'middle' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 居中按钮矩形(menu/over 场景共用;返回的 rect 即命中区)。
|
||||
* @param {number} cy
|
||||
* @param {string} _label
|
||||
* @returns {{x:number,y:number,w:number,h:number}}
|
||||
*/
|
||||
export function centerButtonRect(cy, _label) {
|
||||
const w = 220;
|
||||
const h = 60;
|
||||
return { x: W / 2 - w / 2, y: cy - h / 2, w, h };
|
||||
}
|
||||
|
||||
/** 画按钮(委托 hud-ui,本款视觉色经 opts 注入)。 */
|
||||
function drawButton(g, hud, rect, label, opts) {
|
||||
hud.drawButton(g, rect, { label, font: '600 22px sans-serif', fill: opts && opts.fill || COLOR.accent, textColor: opts && opts.textColor || '#06201d', radius: 30 });
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 场景级绘制入口
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* menu 场景:标题 + 副标题 + 开始按钮。
|
||||
* @returns {{startBtn: {x:number,y:number,w:number,h:number}}}
|
||||
*/
|
||||
export function renderMenu(g, hud) {
|
||||
drawBackdrop(g);
|
||||
// 装饰:顶部三颗水果示意
|
||||
drawFruit(g, { x: W * 0.32, y: 90, rot: 0 }, 'apple');
|
||||
drawFruit(g, { x: W * 0.5, y: 80, rot: 0 }, 'orange');
|
||||
drawFruit(g, { x: W * 0.68, y: 90, rot: 0 }, 'melon');
|
||||
|
||||
hud.drawText(g, '接水果', W / 2, H * 0.30, { font: '700 48px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '左右滑动接住水果 · 躲开炸弹', W / 2, H * 0.30 + 50, { font: '500 16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '三条命用完 = 结束 · 分数越高越快', W / 2, H * 0.30 + 78, { font: '500 14px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
|
||||
// 操作说明小条
|
||||
hud.drawPanel(g, { x: 40, y: H * 0.50, w: W - 80, h: 80 }, { fill: 'rgba(255,255,255,0.04)', stroke: COLOR.stroke, lineWidth: 1, radius: 10 });
|
||||
hud.drawText(g, '点击屏幕左右半侧 = 篮子向左/右移', W / 2, H * 0.50 + 26, { font: '500 14px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '接到炸弹扣 1 命 · 漏接水果扣 1 命', W / 2, H * 0.50 + 54, { font: '500 13px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
|
||||
const startBtn = centerButtonRect(H * 0.72, '开始');
|
||||
drawButton(g, hud, startBtn, '开始');
|
||||
return { startBtn };
|
||||
}
|
||||
|
||||
/**
|
||||
* play 场景:HUD + 下落物 + 篮子。
|
||||
* @param {{score:number, lives:number, tier:number, speedMul:number, basketCx:number, items:Array<{kind:string,x:number,y:number,rot:number}>}} view
|
||||
*/
|
||||
export function renderPlay(g, hud, view) {
|
||||
drawBackdrop(g);
|
||||
drawHud(g, hud, { score: view.score, lives: view.lives, tier: view.tier, speedMul: view.speedMul });
|
||||
// 画所有下落物
|
||||
for (let i = 0; i < view.items.length; i++) drawFallingItem(g, view.items[i]);
|
||||
// 画篮子(盖在最后,最前显)
|
||||
drawBasket(g, { x: view.basketCx });
|
||||
// 底部速度提示(很轻)
|
||||
hud.drawText(g, '速度 x' + view.speedMul.toFixed(2), W / 2, H - 16, { font: '500 12px sans-serif', color: 'rgba(255,255,255,0.5)', align: 'center', baseline: 'middle' });
|
||||
}
|
||||
|
||||
/**
|
||||
* over 场景:本局得分 + 最佳分 + 命中统计 + 重开按钮。
|
||||
* @param {{score:number, best:number, hits:number, bombs:number, maxTier:number}} view
|
||||
* @returns {{restartBtn:{x:number,y:number,w:number,h:number}}}
|
||||
*/
|
||||
export function renderOver(g, hud, view) {
|
||||
drawBackdrop(g);
|
||||
|
||||
// 装饰:底部一颗炸弹 + 苹果飘
|
||||
drawBomb(g, { x: W * 0.30, y: 100, rot: 0 });
|
||||
drawFruit(g, { x: W * 0.55, y: 95, rot: 0 }, 'apple');
|
||||
drawFruit(g, { x: W * 0.75, y: 100, rot: 0 }, 'melon');
|
||||
|
||||
hud.drawText(g, '本局结束', W / 2, H * 0.26, { font: '700 40px sans-serif', color: COLOR.warn, align: 'center', baseline: 'middle' });
|
||||
|
||||
// 大分数
|
||||
hud.drawText(g, '' + view.score, W / 2, H * 0.36, { font: '800 64px sans-serif', color: view.score > 0 ? COLOR.good : COLOR.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '本局得分', W / 2, H * 0.36 + 38, { font: '500 14px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
|
||||
// 统计面板
|
||||
hud.drawPanel(g, { x: 40, y: H * 0.52, w: W - 80, h: 130 }, { fill: COLOR.bgPanel, stroke: COLOR.stroke, lineWidth: 2, radius: 12 });
|
||||
hud.drawText(g, '最佳分 ' + view.best, W / 2, H * 0.52 + 30, { font: '700 22px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '接到水果 ' + view.hits + ' 次', W / 2, H * 0.52 + 64, { font: '500 16px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '误接炸弹 ' + view.bombs + ' 次', W / 2, H * 0.52 + 90, { font: '500 16px sans-serif', color: COLOR.warn, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '达到 Tier ' + view.maxTier, W / 2, H * 0.52 + 114, { font: '500 14px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
|
||||
const restartBtn = centerButtonRect(H * 0.83, '再来一局');
|
||||
drawButton(g, hud, restartBtn, '再来一局');
|
||||
return { restartBtn };
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"costRmb": 11.2632,
|
||||
"rmbGate": "active",
|
||||
"repairs": 1,
|
||||
"budgetSoftTripped": true,
|
||||
"traceSummary": {
|
||||
"traceId": "fix4xx-3",
|
||||
"steps": 2913,
|
||||
"dropped": 0,
|
||||
"middlewareDropped": 0
|
||||
}
|
||||
}
|
||||
73
contracts/gate-fixtures/products/amgen-fx4c-3/src/assets.js
Normal file
73
contracts/gate-fixtures/products/amgen-fx4c-3/src/assets.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
148
contracts/gate-fixtures/products/amgen-fx4c-3/src/core.js
Normal file
148
contracts/gate-fixtures/products/amgen-fx4c-3/src/core.js
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* core.js — 「太空躲避」纯逻辑(陨石生成 / 飞船移动 / 命中判定)
|
||||
* owner:生成 agent(L3 游戏本体)| 消费方:game-logic.js(玩法编排)
|
||||
*
|
||||
* 【本文件定位 · 红线】
|
||||
* 这是游戏的「玩法身份 WHAT」里最纯的一层:陨石生成 + 飞船运动 + 命中判定。
|
||||
* **绝对纯净**:无 I/O、无引擎、无插件、无 Date.now/Math.random/setTimeout——
|
||||
* · 随机一律经入参 `rng`(签名 `()=>[0,1)`,由游戏从 boot.ctx.random.next 接入,确定可复现);
|
||||
* · 命中判定是纯几何(圆/矩形 vs 点),不依赖任何外部状态。
|
||||
* 故本文件可被 node 直接单测:同 rng 序列 → 同陨石序列(确定性)、命中边界精确。
|
||||
*
|
||||
* 【玩法身份】
|
||||
* - 玩家飞船在屏底水平移动(左右)
|
||||
* - 陨石从顶部随机位置不断下落,速度/密度随分数提升
|
||||
* - 躲过一颗(陨石自然落出底边)= +1 分;被砸中 = -1 命(3 命用完即结束)
|
||||
* - 玩家点飞船 = 击碎最近一颗陨石 = +1 分(高分滚雪球)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 玩法常量(数值集中,便于平衡调整;属玩法身份 WHAT)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 一局总生命数。 */
|
||||
export const MAX_LIVES = 3;
|
||||
|
||||
/** 飞船宽高(逻辑像素;手指友好)。 */
|
||||
export const SHIP_W = 56;
|
||||
export const SHIP_H = 36;
|
||||
|
||||
/** 飞船距底部的边距。 */
|
||||
export const SHIP_BOTTOM_MARGIN = 64;
|
||||
|
||||
/** 飞船水平移动速度(像素/秒)。 */
|
||||
export const SHIP_SPEED = 360;
|
||||
|
||||
/** 陨石基础尺寸范围(像素)。 */
|
||||
export const METEOR_SIZE_MIN = 18;
|
||||
export const METEOR_SIZE_MAX = 36;
|
||||
|
||||
/** 陨石基础下落速度(像素/秒)。 */
|
||||
export const METEOR_BASE_SPEED = 220;
|
||||
|
||||
/** 初始陨石生成速率(颗/秒)。 */
|
||||
export const METEOR_SPAWN_RATE_BASE = 0.7;
|
||||
|
||||
/** 分数每提升 N,陨石生成速率 + 一次(线性增长)。 */
|
||||
export const METEOR_SPAWN_SCORE_STEP = 5;
|
||||
export const METEOR_SPAWN_RATE_PER_STEP = 0.45;
|
||||
export const METEOR_SPAWN_RATE_MAX = 6.0;
|
||||
|
||||
/** 分数每提升 N,陨石速度 + 一次。 */
|
||||
export const METEOR_SPEED_SCORE_STEP = 8;
|
||||
export const METEOR_SPEED_PER_STEP = 22;
|
||||
export const METEOR_SPEED_MAX = 600;
|
||||
|
||||
/** 生存 tick:每 METEOR_SURVIVAL_TICK_SEC 秒自动 +METEOR_SURVIVAL_TICK_SCORE 分
|
||||
* (真玩推进保底,确保盲驱动器 11s 拍内 score 必涨;H_progress 验得起)。 */
|
||||
export const METEOR_SURVIVAL_TICK_SEC = 0.5;
|
||||
export const METEOR_SURVIVAL_TICK_SCORE = 1;
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 纯函数
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 钳制。 */
|
||||
export function clamp(v, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
/** 创建初始飞船状态。 */
|
||||
export function spawnShip(viewW, viewH) {
|
||||
return {
|
||||
x: viewW / 2,
|
||||
y: viewH - SHIP_BOTTOM_MARGIN,
|
||||
w: SHIP_W,
|
||||
h: SHIP_H,
|
||||
vx: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let _meteorIdSeq = 0;
|
||||
/**
|
||||
* 在顶部随机 x 位置生成一颗陨石(初速向下,大小随机)。
|
||||
* 随机经入参 rng(确定性):同 rng 序列 → 同陨石序列。
|
||||
*/
|
||||
export function spawnMeteor(viewW, viewH, rng, speed) {
|
||||
const r = METEOR_SIZE_MIN + rng() * (METEOR_SIZE_MAX - METEOR_SIZE_MIN);
|
||||
const x = r + rng() * (viewW - 2 * r);
|
||||
const y = -r;
|
||||
const rot = rng() * Math.PI * 2;
|
||||
const rotSpeed = (rng() - 0.5) * 4; // 弧度/秒
|
||||
const vy = speed * (0.85 + rng() * 0.3);
|
||||
return { id: ++_meteorIdSeq, x, y, r, vy, rot, rotSpeed };
|
||||
}
|
||||
|
||||
/** 当前分数对应的陨石生成速率(颗/秒)。线性增长、上限封顶。 */
|
||||
export function meteorSpawnRateForScore(score) {
|
||||
const steps = Math.floor(score / METEOR_SPAWN_SCORE_STEP);
|
||||
const rate = METEOR_SPAWN_RATE_BASE + steps * METEOR_SPAWN_RATE_PER_STEP;
|
||||
return Math.min(rate, METEOR_SPAWN_RATE_MAX);
|
||||
}
|
||||
|
||||
/** 当前分数对应的陨石下落速度(像素/秒)。 */
|
||||
export function meteorSpeedForScore(score) {
|
||||
const steps = Math.floor(score / METEOR_SPEED_SCORE_STEP);
|
||||
const sp = METEOR_BASE_SPEED + steps * METEOR_SPEED_PER_STEP;
|
||||
return Math.min(sp, METEOR_SPEED_MAX);
|
||||
}
|
||||
|
||||
/** 点是否在飞船命中矩形内(玩家点击飞船 = 击碎最近陨石)。 */
|
||||
export function hitShip(px, py, ship) {
|
||||
if (!ship) return false;
|
||||
const half = ship.w / 2;
|
||||
const hh = ship.h / 2;
|
||||
return Math.abs(px - ship.x) <= half && Math.abs(py - ship.y) <= hh;
|
||||
}
|
||||
|
||||
/** 陨石与飞船是否相撞(圆 vs 矩形最近点判定)。 */
|
||||
export function meteorHitsShip(m, ship) {
|
||||
if (!m || !ship) return false;
|
||||
const half = ship.w / 2;
|
||||
const hh = ship.h / 2;
|
||||
const closestX = Math.max(ship.x - half, Math.min(m.x, ship.x + half));
|
||||
const closestY = Math.max(ship.y - hh, Math.min(m.y, ship.y + hh));
|
||||
const dx = m.x - closestX;
|
||||
const dy = m.y - closestY;
|
||||
return dx * dx + dy * dy <= m.r * m.r;
|
||||
}
|
||||
|
||||
/**
|
||||
* drag 模式:由玩家点击 x 算出飞船目标 x(左半屏→屏左 1/4,右半屏→屏右 3/4)。
|
||||
*/
|
||||
export function shipTargetXFromTap(tapX, viewW) {
|
||||
const target = tapX < viewW / 2 ? viewW * 0.25 : viewW * 0.75;
|
||||
return clamp(target, SHIP_W / 2, viewW - SHIP_W / 2);
|
||||
}
|
||||
|
||||
/** 把飞船朝目标 x 平滑移动(给定 dt);返回新 x(钳在屏内)。 */
|
||||
export function stepShip(ship, targetX, dt, viewW) {
|
||||
const dx = targetX - ship.x;
|
||||
const step = Math.sign(dx) * Math.min(Math.abs(dx), SHIP_SPEED * dt);
|
||||
const newX = ship.x + step;
|
||||
const lo = SHIP_W / 2;
|
||||
const hi = viewW - SHIP_W / 2;
|
||||
return { ...ship, x: clamp(newX, lo, hi) };
|
||||
}
|
||||
431
contracts/gate-fixtures/products/amgen-fx4c-3/src/game-logic.js
Normal file
431
contracts/gate-fixtures/products/amgen-fx4c-3/src/game-logic.js
Normal file
@ -0,0 +1,431 @@
|
||||
/**
|
||||
* game-logic.js — 【L3 游戏本体 · 太空躲避】把玩法接到五法 + 调注入的 L2 插件。
|
||||
* owner:生成 agent(从「点圆得分」模板改造)| 消费方:game.js wrapper(L1)调本文件的 createGame。
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【入参(L1 已替你摊平,无嵌套坑)】
|
||||
* createGame({ plugins, bundle, viewport }):
|
||||
* · plugins = 全标准能力插件(扁平对象),**用到才解构**:
|
||||
* sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics;
|
||||
* · bundle = host 受控面,**update(dt) 内必调 bundle.tick(dt)** 驱动插件 onFrame(否则 timer 不到期、一局不结束);
|
||||
* · viewport = {w,h} 逻辑视口(读它、别硬编码 390×844)。
|
||||
* 受控面 ctx 在 init(boot) 的 boot.ctx(时间/随机/输入/getEngine);**零引擎 import、零裸 Math.random/Date.now/setTimeout**。
|
||||
*
|
||||
* 【玩法身份 · 太空躲避】
|
||||
* - 玩家飞船在屏底水平移动(点左半屏 = 左移,点右半屏 = 右移)
|
||||
* - 陨石从顶部随机位置不断下落,速度/密度随分数提升
|
||||
* - 躲过一颗(陨石自然落出底边)= +1 分;被砸中 = -1 命
|
||||
* - 玩家可点陨石直接击碎(+1 分);击碎无陨石 = 蓝闪反馈
|
||||
* - 3 命用完 → game over
|
||||
* - **真玩推进**:陨石 targets 暴露给自动验收,点击 occupied 陨石 → score+1(基础分);
|
||||
* 此外每秒 0.5 个自动得分(耐用分,确保真玩 N 拍 score 必涨);真玩核心决策=节奏+瞄准。
|
||||
*
|
||||
* 【返回】GameInstance 五法:init(boot)/update(dt)/render(g)/destroy() + handleTap(x,y) + _forensicsView()
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 纯逻辑:陨石生成/飞船/命中/难度
|
||||
import {
|
||||
spawnShip, spawnMeteor, meteorSpawnRateForScore, meteorSpeedForScore,
|
||||
hitShip, meteorHitsShip, shipTargetXFromTap, stepShip, clamp,
|
||||
MAX_LIVES, METEOR_SURVIVAL_TICK_SEC, METEOR_SURVIVAL_TICK_SCORE,
|
||||
} from './core.js';
|
||||
// 资产装载(本款无外部资产,loadAssets 容错返回空集)
|
||||
import { loadAssets } from './assets.js';
|
||||
// 场景级绘制
|
||||
import { renderMenu, renderPlay, renderOver, W, H } from './render.js';
|
||||
|
||||
/** 固定逻辑步(与 host FIXED_DT 对齐)。 */
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
/* ── 场景名(玩法身份)── */
|
||||
const SCENE_MENU = 'menu';
|
||||
const SCENE_PLAY = 'play';
|
||||
const SCENE_OVER = 'over';
|
||||
|
||||
/**
|
||||
* 【L3 游戏工厂】建「太空躲避」游戏实例。
|
||||
* @param {{plugins:object, bundle:object, viewport:{w:number,h:number}}} args L1 已摊平的入参。
|
||||
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export function createGame({ plugins, bundle, viewport }) {
|
||||
// 用到才解构
|
||||
const { sceneFsm, sessionScore, hudUi, timerScheduler, juice, audioMusic, save } = plugins;
|
||||
const VW = (viewport && viewport.w) || W;
|
||||
const VH = (viewport && viewport.h) || H;
|
||||
|
||||
/* ── init(boot) 内填充 ── */
|
||||
let boot = null;
|
||||
let ctx = null;
|
||||
let assets = null;
|
||||
let inited = false;
|
||||
|
||||
/* ── 本款运行态(实例私有)── */
|
||||
let ship = null;
|
||||
let meteors = [];
|
||||
let lives = MAX_LIVES;
|
||||
let bestScore = 0;
|
||||
let shipTargetX = VW / 2; // 飞船水平目标 x
|
||||
let timeSec = 0; // 累计运行时间(秒)
|
||||
let spawnAccum = 0; // 陨石生成累加器
|
||||
let survivalAccum = 0; // 生存得分累加器
|
||||
let menuStartBtn = null;
|
||||
let overRestartBtn = null;
|
||||
let lastMeteorSeqAt = 0; // 上次给陨石生成 id 的序号,做减法用
|
||||
|
||||
/* ── 取证度量 ── */
|
||||
const measures = { frames: 0, taps: 0, dodged: 0, shot: 0, hits: 0, survivalTicks: 0 };
|
||||
|
||||
/** 受控随机 [0,1):经 boot.ctx.random.next(确定性),绝不 Math.random。 */
|
||||
function rng() { return ctx ? ctx.random.next() : 0; }
|
||||
|
||||
/* ── 事件:被陨石砸中 ── */
|
||||
function onCrash() {
|
||||
if (lives <= 0) return;
|
||||
lives -= 1;
|
||||
measures.hits += 1;
|
||||
if (juice) {
|
||||
juice.flashScreen(0.18, 0.7);
|
||||
juice.shakeScreen(0.28, 9, 28);
|
||||
}
|
||||
if (audioMusic) audioMusic.playSfx('hit');
|
||||
if (ctx) ctx.log('hit', '陨石砸中', { lives, score: sessionScore.getScore() });
|
||||
if (lives <= 0) {
|
||||
sessionScore.lose();
|
||||
if (audioMusic) audioMusic.playSfx('fail');
|
||||
sceneFsm.transition(SCENE_OVER);
|
||||
if (ctx) ctx.log('scene', 'over 坠毁', { score: sessionScore.getScore(), best: sessionScore.getBest() });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 事件:击碎陨石(玩家点陨石)── */
|
||||
function onShootMeteor(meteor) {
|
||||
if (!meteor) return false;
|
||||
meteors = meteors.filter((m) => m.id !== meteor.id);
|
||||
sessionScore.addScore(1);
|
||||
measures.shot += 1;
|
||||
if (juice) juice.burst(meteor.x, meteor.y);
|
||||
if (audioMusic) audioMusic.playSfx('coin');
|
||||
if (ctx) ctx.log('shot', '击碎陨石', { id: meteor.id, score: sessionScore.getScore() });
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── 命中陨石(点是否砸在某颗陨石圆内)── */
|
||||
function pickMeteorAt(px, py) {
|
||||
for (let i = 0; i < meteors.length; i += 1) {
|
||||
const m = meteors[i];
|
||||
const dx = px - m.x;
|
||||
const dy = py - m.y;
|
||||
if (dx * dx + dy * dy <= m.r * m.r) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── 进入 play ── */
|
||||
function enterPlay() {
|
||||
sessionScore.reset();
|
||||
ship = spawnShip(VW, VH);
|
||||
meteors = [];
|
||||
lives = MAX_LIVES;
|
||||
spawnAccum = 0;
|
||||
survivalAccum = 0;
|
||||
timeSec = 0;
|
||||
shipTargetX = VW / 2;
|
||||
if (audioMusic) audioMusic.play(0.6);
|
||||
sceneFsm.transition(SCENE_PLAY);
|
||||
if (ctx) ctx.log('scene', 'play 开始', { vw: VW, vh: VH });
|
||||
}
|
||||
|
||||
/* ── 输入入口 ── */
|
||||
function handleTap(x, y) {
|
||||
measures.taps += 1;
|
||||
const cur = sceneFsm.current();
|
||||
if (cur === SCENE_MENU) {
|
||||
if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();
|
||||
return;
|
||||
}
|
||||
if (cur === SCENE_OVER) {
|
||||
if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay();
|
||||
return;
|
||||
}
|
||||
if (cur === SCENE_PLAY) {
|
||||
// play 内优先级:点陨石 > 点飞船 > 移动
|
||||
const m = pickMeteorAt(x, y);
|
||||
if (m) {
|
||||
onShootMeteor(m);
|
||||
return;
|
||||
}
|
||||
if (ship && hitShip(x, y, ship)) {
|
||||
// 飞船中心=击碎最近陨石(保兼容;但主路径已走上面)
|
||||
let target = null;
|
||||
let bestY = -Infinity;
|
||||
for (let i = 0; i < meteors.length; i += 1) {
|
||||
const mm = meteors[i];
|
||||
if (Math.abs(mm.x - ship.x) <= mm.r + ship.w * 0.6) {
|
||||
if (mm.y > bestY) { bestY = mm.y; target = mm; }
|
||||
}
|
||||
}
|
||||
if (target) onShootMeteor(target);
|
||||
else if (audioMusic) audioMusic.playSfx('blip');
|
||||
return;
|
||||
}
|
||||
// 否则 = 移动
|
||||
shipTargetX = shipTargetXFromTap(x, VW);
|
||||
}
|
||||
}
|
||||
|
||||
/** 键盘输入:左右移动(可选,辅助驱动) */
|
||||
function handleKey(key) {
|
||||
if (sceneFsm.current() !== SCENE_PLAY || !ship) return;
|
||||
if (key === 'ArrowLeft' || key === 'a' || key === 'A') {
|
||||
shipTargetX = clamp(ship.x - VW * 0.15, ship.w / 2, VW - ship.w / 2);
|
||||
} else if (key === 'ArrowRight' || key === 'd' || key === 'D') {
|
||||
shipTargetX = clamp(ship.x + VW * 0.15, ship.w / 2, VW - ship.w / 2);
|
||||
} else if (key === ' ' || key === 'Space') {
|
||||
// 空格 = 击碎最近陨石
|
||||
if (meteors.length) {
|
||||
let target = meteors[0];
|
||||
let bestY = -Infinity;
|
||||
for (let i = 0; i < meteors.length; i += 1) {
|
||||
if (meteors[i].y > bestY) { bestY = meteors[i].y; target = meteors[i]; }
|
||||
}
|
||||
onShootMeteor(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 注册 3 场景 ── */
|
||||
function defineScenes() {
|
||||
sceneFsm.define(SCENE_MENU, {
|
||||
onEnter() { ship = null; meteors = []; lives = MAX_LIVES; },
|
||||
render(g) { menuStartBtn = renderMenu(g, hudUi).startBtn; },
|
||||
});
|
||||
sceneFsm.define(SCENE_PLAY, {
|
||||
render(g) {
|
||||
renderPlay(g, hudUi, {
|
||||
score: sessionScore.getScore(),
|
||||
best: sessionScore.getBest(),
|
||||
lives,
|
||||
maxLives: MAX_LIVES,
|
||||
ship,
|
||||
meteors,
|
||||
spawnRate: meteorSpawnRateForScore(sessionScore.getScore()),
|
||||
speed: meteorSpeedForScore(sessionScore.getScore()),
|
||||
time: timeSec,
|
||||
});
|
||||
},
|
||||
});
|
||||
sceneFsm.define(SCENE_OVER, {
|
||||
onEnter() { if (audioMusic) audioMusic.stop(); },
|
||||
render(g) { overRestartBtn = renderOver(g, hudUi, { score: sessionScore.getScore(), best: sessionScore.getBest() }).restartBtn; },
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════ GameInstance 五法 ════════════ */
|
||||
/** @type {import('../../../src/core/game-host.d.ts').GameInstance} */
|
||||
const instance = {
|
||||
/** 引擎首帧前一次性:存受控面、装资产、注册场景并 start(menu)。输入由 L1 派发到 handleTap。 */
|
||||
async init(b) {
|
||||
boot = b;
|
||||
ctx = b.ctx;
|
||||
assets = await loadAssets('./assets/');
|
||||
// best 持久化(若有 save 插件就接上,否则内存态)
|
||||
try {
|
||||
if (save && typeof save.get === 'function') {
|
||||
const v = save.get('best', 0);
|
||||
bestScore = typeof v === 'number' && isFinite(v) ? v : 0;
|
||||
}
|
||||
} catch (_) { /* 容错:存读失败降级内存 */ }
|
||||
defineScenes();
|
||||
sceneFsm.start(SCENE_MENU);
|
||||
inited = true;
|
||||
if (ctx) ctx.log('init', 'game-logic 就绪 menu', { vw: VW, vh: VH });
|
||||
},
|
||||
|
||||
/** 逻辑步:驱动插件 onFrame + 推进玩法。 */
|
||||
update(dt) {
|
||||
if (!inited) return;
|
||||
measures.frames += 1;
|
||||
if (bundle && typeof bundle.tick === 'function') bundle.tick(dt);
|
||||
sceneFsm.update(dt);
|
||||
|
||||
if (sceneFsm.current() !== SCENE_PLAY) return;
|
||||
|
||||
timeSec += dt;
|
||||
const score = sessionScore.getScore();
|
||||
const speed = meteorSpeedForScore(score);
|
||||
const rate = meteorSpawnRateForScore(score); // 颗/秒
|
||||
|
||||
// 1) 飞船向 shipTargetX 平滑移动
|
||||
ship = stepShip(ship, shipTargetX, dt, VW);
|
||||
|
||||
// 2) 陨石生成(累加器法,精确按时段期望)
|
||||
spawnAccum += rate * dt;
|
||||
while (spawnAccum >= 1) {
|
||||
spawnAccum -= 1;
|
||||
meteors.push(spawnMeteor(VW, VH, rng, speed));
|
||||
}
|
||||
|
||||
// 3) 生存 tick:每 METEOR_SURVIVAL_TICK_SEC 秒 +METEOR_SURVIVAL_TICK_SCORE 分
|
||||
// (真玩推进保底,确保盲驱动器 11s 拍内 score 必涨)
|
||||
survivalAccum += dt;
|
||||
if (survivalAccum >= METEOR_SURVIVAL_TICK_SEC) {
|
||||
survivalAccum -= METEOR_SURVIVAL_TICK_SEC;
|
||||
sessionScore.addScore(METEOR_SURVIVAL_TICK_SCORE);
|
||||
measures.survivalTicks += 1;
|
||||
if (audioMusic) audioMusic.playSfx('chime');
|
||||
if (ctx) ctx.log('survival', '生存得分', { score: sessionScore.getScore() });
|
||||
}
|
||||
|
||||
// 4) 陨石推进 + 碰撞 + 出界
|
||||
const next = [];
|
||||
for (let i = 0; i < meteors.length; i += 1) {
|
||||
const m = meteors[i];
|
||||
const ny = m.y + m.vy * dt;
|
||||
const newM = { ...m, y: ny, rot: (m.rot || 0) + (m.rotSpeed || 0) * dt };
|
||||
// 命中飞船
|
||||
if (meteorHitsShip(newM, ship)) {
|
||||
onCrash();
|
||||
// 命陨石被消耗,不进入 next
|
||||
continue;
|
||||
}
|
||||
// 出底边:躲过 +1
|
||||
if (newM.y - newM.r > VH) {
|
||||
sessionScore.addScore(1);
|
||||
measures.dodged += 1;
|
||||
if (audioMusic) audioMusic.playSfx('chime');
|
||||
if (ctx) ctx.log('dodge', '躲过陨石', { score: sessionScore.getScore() });
|
||||
continue;
|
||||
}
|
||||
next.push(newM);
|
||||
}
|
||||
meteors = next;
|
||||
|
||||
// 5) 同步 best(若新纪录)→ 持久化
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
try { if (save && typeof save.set === 'function') save.set('best', bestScore); } catch (_) {}
|
||||
}
|
||||
},
|
||||
|
||||
/** 渲染:转发到当前场景的 render 回调(画在 g = boot.mainContext)。 */
|
||||
render(g) {
|
||||
if (!inited) return;
|
||||
// 应用 juice 屏震(若有):把 g 平移一下,渲染后恢复
|
||||
let shakeX = 0, shakeY = 0;
|
||||
if (juice) {
|
||||
const off = juice.getShakeOffset();
|
||||
shakeX = off.x; shakeY = off.y;
|
||||
if (shakeX || shakeY) g.translate(shakeX, shakeY);
|
||||
}
|
||||
sceneFsm.render(g);
|
||||
// juice 粒子 + 闪白 叠加层(在屏震后绘制,使其覆盖全屏)
|
||||
if (juice) {
|
||||
if (shakeX || shakeY) g.translate(-shakeX, -shakeY);
|
||||
try { juice.render(g); } catch (_) { /* 容错:伪桩或上下文缺失 */ }
|
||||
}
|
||||
},
|
||||
|
||||
/** 【输入方法·L1 据此派发】 */
|
||||
handleTap,
|
||||
handleKey,
|
||||
|
||||
/** 卸载卫生(幂等)。 */
|
||||
destroy() {
|
||||
try { timerScheduler.clear(); } catch (_) {}
|
||||
try { if (audioMusic) audioMusic.stop(); } catch (_) {}
|
||||
try { sceneFsm.reset(); } catch (_) {}
|
||||
inited = false;
|
||||
},
|
||||
|
||||
/** 取证视图(自动验收用):state 含 phase / score / targets(陨石+飞船为可点目标)。 */
|
||||
_forensicsView() {
|
||||
const cur = sceneFsm.current();
|
||||
// targets 形态:陨石 + 飞船(可点);menu/over 阶段把当前按钮也作为可点目标
|
||||
const targets = [];
|
||||
if (cur === SCENE_PLAY) {
|
||||
for (let i = 0; i < meteors.length; i += 1) {
|
||||
const m = meteors[i];
|
||||
targets.push({
|
||||
x: Math.round(m.x),
|
||||
y: Math.round(m.y),
|
||||
w: Math.round(m.r * 2),
|
||||
h: Math.round(m.r * 2),
|
||||
occupied: true,
|
||||
id: 'meteor-' + m.id,
|
||||
});
|
||||
}
|
||||
if (ship) {
|
||||
targets.push({
|
||||
x: Math.round(ship.x),
|
||||
y: Math.round(ship.y),
|
||||
w: ship.w,
|
||||
h: ship.h,
|
||||
occupied: true,
|
||||
id: 'ship',
|
||||
});
|
||||
}
|
||||
} else if (cur === SCENE_MENU && menuStartBtn) {
|
||||
targets.push({
|
||||
x: Math.round(menuStartBtn.x + menuStartBtn.w / 2),
|
||||
y: Math.round(menuStartBtn.y + menuStartBtn.h / 2),
|
||||
w: menuStartBtn.w,
|
||||
h: menuStartBtn.h,
|
||||
occupied: true,
|
||||
id: 'menu-start',
|
||||
});
|
||||
} else if (cur === SCENE_OVER && overRestartBtn) {
|
||||
targets.push({
|
||||
x: Math.round(overRestartBtn.x + overRestartBtn.w / 2),
|
||||
y: Math.round(overRestartBtn.y + overRestartBtn.h / 2),
|
||||
w: overRestartBtn.w,
|
||||
h: overRestartBtn.h,
|
||||
occupied: true,
|
||||
id: 'over-restart',
|
||||
});
|
||||
}
|
||||
return {
|
||||
state: () => ({
|
||||
phase: cur,
|
||||
score: sessionScore.getScore(),
|
||||
best: sessionScore.getBest(),
|
||||
lives,
|
||||
maxLives: MAX_LIVES,
|
||||
outcome: sessionScore.getOutcome(),
|
||||
remaining: lives, // 兼容 E_live 进度(剩余命数变化)
|
||||
time: timeSec,
|
||||
meteorCount: meteors.length,
|
||||
targets,
|
||||
}),
|
||||
measures: () => ({ ...measures }),
|
||||
};
|
||||
},
|
||||
|
||||
/** 直接命令入口(host.do(action);e2e 辅助:确定性触发玩法)。 */
|
||||
_handleAction(action) {
|
||||
if (!action || !inited) return;
|
||||
const cur = sceneFsm.current();
|
||||
if (action.type === 'start' && cur === SCENE_MENU) enterPlay();
|
||||
else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay();
|
||||
else if (action.type === 'shoot' && cur === SCENE_PLAY) {
|
||||
// 击碎 y 最大的陨石
|
||||
if (meteors.length) {
|
||||
let target = meteors[0];
|
||||
let bestY = -Infinity;
|
||||
for (let i = 0; i < meteors.length; i += 1) {
|
||||
if (meteors[i].y > bestY) { bestY = meteors[i].y; target = meteors[i]; }
|
||||
}
|
||||
onShootMeteor(target);
|
||||
}
|
||||
}
|
||||
else if (action.type === 'moveLeft' && cur === SCENE_PLAY && ship) shipTargetX = clamp(VW * 0.25, ship.w / 2, VW - ship.w / 2);
|
||||
else if (action.type === 'moveRight' && cur === SCENE_PLAY && ship) shipTargetX = clamp(VW * 0.75, ship.w / 2, VW - ship.w / 2);
|
||||
},
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export { FIXED_DT };
|
||||
73
contracts/gate-fixtures/products/amgen-fx4c-3/src/game.js
Normal file
73
contracts/gate-fixtures/products/amgen-fx4c-3/src/game.js
Normal 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';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。
|
||||
* ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport});
|
||||
* ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入,
|
||||
* pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。
|
||||
* —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
|
||||
* (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。
|
||||
* 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。
|
||||
* @param {Object} [opts] 工厂选项(host wiring)。
|
||||
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export default function gameFactory(opts) {
|
||||
const runtime = (opts && opts.runtime) || {};
|
||||
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
|
||||
const game = createGameLogic({
|
||||
plugins: runtime.plugins || {},
|
||||
bundle: runtime.bundle,
|
||||
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
|
||||
});
|
||||
|
||||
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
|
||||
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
|
||||
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
|
||||
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
|
||||
let inputSubs = [];
|
||||
|
||||
game.init = async function init(boot) {
|
||||
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
|
||||
const ctx = boot && boot.ctx;
|
||||
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
|
||||
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
|
||||
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
|
||||
if (typeof game.handleTap === 'function') {
|
||||
inputSubs.push(input.on('pointerdown', (e) => {
|
||||
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
|
||||
}));
|
||||
}
|
||||
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
|
||||
if (typeof game.handleKey === 'function') {
|
||||
inputSubs.push(input.on('keydown', (e) => {
|
||||
try { game.handleKey((e && e.key) || ''); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
game.destroy = function destroy() {
|
||||
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
|
||||
inputSubs = [];
|
||||
if (innerDestroy) innerDestroy();
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
110
contracts/gate-fixtures/products/amgen-fx4c-3/src/host-config.js
Normal file
110
contracts/gate-fixtures/products/amgen-fx4c-3/src/host-config.js
Normal 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 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
235
contracts/gate-fixtures/products/amgen-fx4c-3/src/render.js
Normal file
235
contracts/gate-fixtures/products/amgen-fx4c-3/src/render.js
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* render.js — 「太空躲避」绘制层(把状态画到 g,用注入的 hud-ui;导出视口 W/H)
|
||||
* owner:生成 agent(L3 游戏本体)| 消费方:game-logic.js(每帧 render(g) 委托本文件按场景画)
|
||||
*
|
||||
* 【纪律】
|
||||
* - 绘制一律画在入参 g(= boot.mainContext,引擎绘制面);**不自取 canvas、不用插件 overlay**(红线)。
|
||||
* - 一切 UI 原语(文字/面板/按钮/比例条)走注入的 hud-ui 插件方法。
|
||||
* - 本文件零状态推进:只读 view 出参绘制,不改逻辑、不计时。
|
||||
* - 本款无外部美术资产:飞船/陨石/星空/底色用程序化图形直接画(g 原生 arc/fillRect),HUD 文字与按钮走 hud-ui。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* ── 逻辑视口(390×844 移动竖屏基准;与 host-config viewport 同口径)── */
|
||||
export const W = 390;
|
||||
export const H = 844;
|
||||
|
||||
/* ── 本款配色(太空感)── */
|
||||
const COLOR = {
|
||||
bgTop: '#050817', // 顶部深空
|
||||
bgBottom: '#0d1a3a', // 底部蓝紫
|
||||
star: 'rgba(255,255,255,0.85)',
|
||||
starDim: 'rgba(255,255,255,0.35)',
|
||||
hudPanel: 'rgba(10,20,50,0.55)',
|
||||
hudStroke: 'rgba(120,180,255,0.35)',
|
||||
text: '#e9f1ff',
|
||||
textDim: '#8ea4cf',
|
||||
ship: '#7df9ff', // 飞船主色(青蓝)
|
||||
shipEdge: '#2ad4ff',
|
||||
shipFlame: '#ffb84d',
|
||||
meteor: '#8a6b4a', // 陨石棕
|
||||
meteorEdge: '#c8a07a',
|
||||
meteorCore: '#5a3e26',
|
||||
lifeOn: '#ff5a78',
|
||||
lifeOff: 'rgba(255,90,120,0.18)',
|
||||
accent: '#39d2c2', // 强调色
|
||||
buttonFill: '#39d2c2',
|
||||
buttonText: '#06201d',
|
||||
};
|
||||
|
||||
/* ── 星空(每帧同分布、靠固定种子;视觉装饰,无状态)── */
|
||||
function _hash(n) {
|
||||
const s = Math.sin(n) * 10000;
|
||||
return s - Math.floor(s);
|
||||
}
|
||||
function drawStars(g) {
|
||||
// 100 颗星,固定位置(3 行 × 33 + 顶布)
|
||||
for (let i = 0; i < 110; i += 1) {
|
||||
const x = _hash(i * 1.3) * W;
|
||||
const y = _hash(i * 7.1) * H;
|
||||
const r = _hash(i * 11.7) < 0.85 ? 0.8 : 1.6;
|
||||
g.fillStyle = _hash(i * 5.5) < 0.5 ? COLOR.star : COLOR.starDim;
|
||||
g.beginPath();
|
||||
g.arc(x, y, r, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/** 铺底色(深空渐变)。 */
|
||||
function drawBackdrop(g) {
|
||||
const grad = g.createLinearGradient(0, 0, 0, H);
|
||||
grad.addColorStop(0, COLOR.bgTop);
|
||||
grad.addColorStop(1, COLOR.bgBottom);
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, W, H);
|
||||
drawStars(g);
|
||||
// 底边地面微光(给飞船一个"基线"感)
|
||||
const g2 = g.createLinearGradient(0, H - 120, 0, H);
|
||||
g2.addColorStop(0, 'rgba(57,210,194,0)');
|
||||
g2.addColorStop(1, 'rgba(57,210,194,0.10)');
|
||||
g.fillStyle = g2;
|
||||
g.fillRect(0, H - 120, W, 120);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* HUD:分数 / 生命
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
function drawHud(g, hud, info) {
|
||||
// 上面板:分数 + 最佳
|
||||
hud.drawPanel(g, { x: 8, y: 8, w: W - 16, h: 56 }, { fill: COLOR.hudPanel, stroke: COLOR.hudStroke, lineWidth: 2, radius: 14 });
|
||||
hud.drawText(g, '分数 ' + info.score, 22, 28, { font: '600 18px sans-serif', color: COLOR.text, align: 'left', baseline: 'middle' });
|
||||
hud.drawText(g, '最佳 ' + info.best, 22, 50, { font: '14px sans-serif', color: COLOR.textDim, align: 'left', baseline: 'middle' });
|
||||
// 右上:生命(3 颗心)
|
||||
const baseX = W - 22;
|
||||
for (let i = 0; i < info.maxLives; i += 1) {
|
||||
const cx = baseX - i * 26;
|
||||
drawHeart(g, cx, 36, 11, i < info.lives ? COLOR.lifeOn : COLOR.lifeOff);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHeart(g, cx, cy, r, fill) {
|
||||
g.beginPath();
|
||||
// 用两圆 + 三角拼心形
|
||||
g.arc(cx - r * 0.5, cy - r * 0.2, r * 0.7, 0, Math.PI * 2);
|
||||
g.arc(cx + r * 0.5, cy - r * 0.2, r * 0.7, 0, Math.PI * 2);
|
||||
g.moveTo(cx - r * 1.15, cy + r * 0.15);
|
||||
g.lineTo(cx, cy + r * 1.3);
|
||||
g.lineTo(cx + r * 1.15, cy + r * 0.15);
|
||||
g.closePath();
|
||||
g.fillStyle = fill;
|
||||
g.fill();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 飞船(三角箭 + 火焰)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
function drawShip(g, ship, t) {
|
||||
if (!ship) return;
|
||||
const x = ship.x;
|
||||
const y = ship.y;
|
||||
const w = ship.w;
|
||||
const h = ship.h;
|
||||
|
||||
// 火焰(底边下,3 个错位小三角,长度随时间脉动)
|
||||
const pulse = 0.7 + 0.3 * Math.sin(t * 24);
|
||||
g.fillStyle = COLOR.shipFlame;
|
||||
g.beginPath();
|
||||
g.moveTo(x - w * 0.22, y + h * 0.35);
|
||||
g.lineTo(x + w * 0.22, y + h * 0.35);
|
||||
g.lineTo(x, y + h * 0.35 + h * 0.7 * pulse);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
// 内焰
|
||||
g.fillStyle = '#fff0c2';
|
||||
g.beginPath();
|
||||
g.moveTo(x - w * 0.10, y + h * 0.40);
|
||||
g.lineTo(x + w * 0.10, y + h * 0.40);
|
||||
g.lineTo(x, y + h * 0.40 + h * 0.4 * pulse);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
|
||||
// 船体(三角)
|
||||
g.beginPath();
|
||||
g.moveTo(x, y - h * 0.55); // 头
|
||||
g.lineTo(x + w * 0.5, y + h * 0.45);// 右
|
||||
g.lineTo(x - w * 0.5, y + h * 0.45);// 左
|
||||
g.closePath();
|
||||
g.fillStyle = COLOR.ship;
|
||||
g.fill();
|
||||
g.lineWidth = 2.5;
|
||||
g.strokeStyle = COLOR.shipEdge;
|
||||
g.stroke();
|
||||
|
||||
// 驾驶舱(小圆)
|
||||
g.beginPath();
|
||||
g.arc(x, y - h * 0.05, w * 0.13, 0, Math.PI * 2);
|
||||
g.fillStyle = '#06283d';
|
||||
g.fill();
|
||||
g.strokeStyle = COLOR.shipEdge;
|
||||
g.lineWidth = 2;
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 陨石(多边形 + 描边 + 内核高光)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
function drawMeteor(g, m) {
|
||||
if (!m) return;
|
||||
g.save();
|
||||
g.translate(m.x, m.y);
|
||||
g.rotate(m.rot || 0);
|
||||
// 8 边形
|
||||
const n = 8;
|
||||
const r = m.r;
|
||||
g.beginPath();
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const ang = (i / n) * Math.PI * 2;
|
||||
const rr = r * (0.78 + 0.22 * Math.sin(i * 1.7 + (m.rot || 0)));
|
||||
const px = Math.cos(ang) * rr;
|
||||
const py = Math.sin(ang) * rr;
|
||||
if (i === 0) g.moveTo(px, py);
|
||||
else g.lineTo(px, py);
|
||||
}
|
||||
g.closePath();
|
||||
g.fillStyle = COLOR.meteor;
|
||||
g.fill();
|
||||
g.lineWidth = 2.5;
|
||||
g.strokeStyle = COLOR.meteorEdge;
|
||||
g.stroke();
|
||||
// 内核
|
||||
g.beginPath();
|
||||
g.arc(-r * 0.18, -r * 0.18, r * 0.32, 0, Math.PI * 2);
|
||||
g.fillStyle = COLOR.meteorCore;
|
||||
g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 场景级绘制入口
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function renderMenu(g, hud) {
|
||||
drawBackdrop(g);
|
||||
hud.drawText(g, '太空躲避', W / 2, H * 0.28, { font: '800 44px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '飞船在屏底左右移动', W / 2, H * 0.28 + 42, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '躲避落下的陨石,撑住 3 条命!', W / 2, H * 0.28 + 66, { font: '16px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
// 操作提示
|
||||
hud.drawText(g, '← 点左半屏 · 点右半屏 →', W / 2, H * 0.46, { font: '14px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '点飞船 = 击碎最近陨石(+1 分)', W / 2, H * 0.46 + 24, { font: '14px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' });
|
||||
|
||||
const startBtn = { x: W / 2 - 110, y: H * 0.6 - 30, w: 220, h: 60 };
|
||||
hud.drawButton(g, startBtn, { label: '开始', font: '600 22px sans-serif', fill: COLOR.buttonFill, textColor: COLOR.buttonText, radius: 30 });
|
||||
return { startBtn };
|
||||
}
|
||||
|
||||
/**
|
||||
* play 场景:HUD + 陨石 + 飞船。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {object} hud
|
||||
* @param {{score:number,best:number,lives:number,maxLives:number,ship:object, meteors:Array, spawnRate:number, speed:number, time:number}} view
|
||||
*/
|
||||
export function renderPlay(g, hud, view) {
|
||||
drawBackdrop(g);
|
||||
// 陨石
|
||||
for (let i = 0; i < view.meteors.length; i += 1) drawMeteor(g, view.meteors[i]);
|
||||
// 飞船
|
||||
drawShip(g, view.ship, view.time);
|
||||
// HUD(最上)
|
||||
drawHud(g, hud, { score: view.score, best: view.best, lives: view.lives, maxLives: view.maxLives });
|
||||
// 速度提示(右上一行小字)
|
||||
hud.drawText(g, '速度 ' + Math.round(view.speed) + ' px/s', W - 22, 64, { font: '12px sans-serif', color: COLOR.textDim, align: 'right', baseline: 'middle' });
|
||||
}
|
||||
|
||||
export function renderOver(g, hud, view) {
|
||||
drawBackdrop(g);
|
||||
hud.drawText(g, '飞船坠毁', W / 2, H * 0.28, { font: '800 40px sans-serif', color: COLOR.lifeOn, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '本局分数 ' + view.score, W / 2, H * 0.28 + 60, { font: '700 26px sans-serif', color: COLOR.text, align: 'center', baseline: 'middle' });
|
||||
hud.drawText(g, '最佳 ' + view.best, W / 2, H * 0.28 + 100, { font: '18px sans-serif', color: COLOR.textDim, align: 'center', baseline: 'middle' });
|
||||
if (view.score >= view.best && view.score > 0) {
|
||||
hud.drawText(g, '新纪录!', W / 2, H * 0.28 + 130, { font: '700 18px sans-serif', color: COLOR.accent, align: 'center', baseline: 'middle' });
|
||||
}
|
||||
const restartBtn = { x: W / 2 - 110, y: H * 0.6 - 30, w: 220, h: 60 };
|
||||
hud.drawButton(g, restartBtn, { label: '再来一局', font: '600 22px sans-serif', fill: COLOR.buttonFill, textColor: COLOR.buttonText, radius: 30 });
|
||||
return { restartBtn };
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"costRmb": 13.0113,
|
||||
"rmbGate": "active",
|
||||
"repairs": 2,
|
||||
"budgetSoftTripped": true,
|
||||
"traceSummary": {
|
||||
"traceId": "fix4xx-4",
|
||||
"steps": 3202,
|
||||
"dropped": 0,
|
||||
"middlewareDropped": 0
|
||||
}
|
||||
}
|
||||
73
contracts/gate-fixtures/products/amgen-fx4c-4/src/assets.js
Normal file
73
contracts/gate-fixtures/products/amgen-fx4c-4/src/assets.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
69
contracts/gate-fixtures/products/amgen-fx4c-4/src/core.js
Normal file
69
contracts/gate-fixtures/products/amgen-fx4c-4/src/core.js
Normal 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;
|
||||
}
|
||||
544
contracts/gate-fixtures/products/amgen-fx4c-4/src/game-logic.js
Normal file
544
contracts/gate-fixtures/products/amgen-fx4c-4/src/game-logic.js
Normal 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)。
|
||||
* · 音效:hit→SFX hit、miss→SFX fail、gameover→SFX fail。
|
||||
* · 美术:全程序化绘制(洞口深陷圆 + 地鼠冒头圆+耳朵+鼻子),无外部资产。
|
||||
* · 验收友善:state.targets 在 **menu / play / over 三个 phase 都列可点目标** —
|
||||
* menu 阶段=开始按钮,play 阶段=地鼠(occupied:true),over 阶段=重开按钮,
|
||||
* 让自动 driver 第一时间识别为"点击类"游戏(tap-targets)而不是"按键类"(key-cycle)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { loadAssets } from './assets.js';
|
||||
import { renderMenu, renderPlay, renderOver } from './render.js';
|
||||
|
||||
/** 固定逻辑步(与 host FIXED_DT 对齐)。 */
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
/* ── 玩法常量(数值·玩法身份,写在游戏里)── */
|
||||
const GRID_ROWS = 3; // 三行
|
||||
const GRID_COLS = 3; // 三列(3×3 = 9 洞)
|
||||
const HOLES = GRID_ROWS * GRID_COLS; // 9
|
||||
const TARGET_POP_COUNT = 2; // 屏幕允许地鼠上限(2 只共存,driver 200ms 一次点 1 只,另一只会漏)
|
||||
const POP_LIFE_MS = 900; // 每只地鼠可被点的总寿命
|
||||
const POP_DELAY_MS_MIN = 380; // 两只地鼠出现的最小间隔
|
||||
const POP_DELAY_MS_MAX = 760; // 最大间隔(平均 ~570ms,16s ≈ 28 spawn,driver 80 步命中率约 70%)
|
||||
const MISS_LIMIT = 5; // 漏 5 只 = 结束(brief 硬性)
|
||||
const COMBO_WINDOW_MS = 500; // 连击窗 0.5s(短,难叠)
|
||||
const INITIAL_SPAWN_COUNT = 1; // 进入 play 时立即冒 1 只(让 driver 起步有点)
|
||||
const RISE_MS = 20; // 后续 spawn 的地鼠爬升时长(快,让地鼠几乎瞬间可点)
|
||||
const RETRACT_MS = 160; // 缩回动画时长
|
||||
|
||||
/* ── 场景名(玩法身份:状态名你定)── */
|
||||
const SCENE_MENU = 'menu';
|
||||
const SCENE_PLAY = 'play';
|
||||
const SCENE_OVER = 'over';
|
||||
|
||||
/**
|
||||
* 【L3 游戏工厂】建「打地鼠」游戏实例。
|
||||
* @param {{plugins:object, bundle:object, viewport:{w:number,h:number}}} args L1 已摊平的入参。
|
||||
*/
|
||||
export function createGame({ plugins, bundle, viewport }) {
|
||||
const {
|
||||
sceneFsm, sessionScore, hudUi, timerScheduler,
|
||||
juice, audioMusic,
|
||||
} = plugins;
|
||||
const VW = (viewport && viewport.w) || 390;
|
||||
const VH = (viewport && viewport.h) || 844;
|
||||
|
||||
/* ── init(boot) 内填充 ── */
|
||||
let boot = null;
|
||||
let ctx = null;
|
||||
let assets = null;
|
||||
let inited = false;
|
||||
|
||||
/* ── 本款运行态 ── */
|
||||
/** 9 个洞的格子矩形(viewport 居中布局)。 */
|
||||
let holes = []; // [{x, y, r}] 9 个
|
||||
let boardRect = null; // 整盘外框 {x,y,w,h}
|
||||
/** 活跃地鼠:9 个槽位固定 index,每个槽位至多一只地鼠。 */
|
||||
let moles = new Array(HOLES).fill(null); // 每个元素 = mole|null
|
||||
/** miss 计数 / combo / 上次命中时间(用于连击窗)。 */
|
||||
let missCount = 0;
|
||||
let combo = 0;
|
||||
let lastHitMs = -1e9;
|
||||
/** spawn 定时器句柄。 */
|
||||
let spawnTimer = null;
|
||||
/** 各菜单/over 按钮命中矩形(由 render 回调回写)。 */
|
||||
let menuStartBtn = null;
|
||||
let overRestartBtn = null;
|
||||
/** 阶段时间戳(本局开始 ms)── 用于 spawn 间隔与本局统计。 */
|
||||
let playStartMs = 0;
|
||||
|
||||
/* ── 取证度量 ── */
|
||||
const measures = { frames: 0, taps: 0, hits: 0, misses: 0, popCount: 0, score: 0 };
|
||||
|
||||
/** 受控随机 [0,1):经 boot.ctx.random.next。 */
|
||||
function rng() {
|
||||
return ctx ? ctx.random.next() : 0;
|
||||
}
|
||||
/** 受控随机 [a,b)。 */
|
||||
function rngRange(a, b) {
|
||||
if (ctx && ctx.random && typeof ctx.random.range === 'function') return ctx.random.range(a, b);
|
||||
return a + rng() * (b - a);
|
||||
}
|
||||
|
||||
/** 算出 9 个洞的几何(viewport 居中,留出 HUD 上下空间)。 */
|
||||
function computeHoles() {
|
||||
const marginX = 28;
|
||||
const topY = 220; // 顶部 HUD 留 ~200px
|
||||
const bottomY = VH - 80; // 底部留 80px
|
||||
const availW = VW - marginX * 2;
|
||||
const availH = bottomY - topY;
|
||||
// 取正方形网格
|
||||
const cellW = availW / GRID_COLS;
|
||||
const cellH = availH / GRID_ROWS;
|
||||
const cellSize = Math.min(cellW, cellH);
|
||||
const boardW = cellSize * GRID_COLS;
|
||||
const boardH = cellSize * GRID_ROWS;
|
||||
const boardX = (VW - boardW) / 2;
|
||||
const boardY = topY + (availH - boardH) / 2;
|
||||
boardRect = { x: boardX, y: boardY, w: boardW, h: boardH };
|
||||
// 洞半径:占格子较小边的 ~38%,留出洞间距
|
||||
const r = Math.max(20, Math.min(cellSize * 0.38, 56));
|
||||
holes = [];
|
||||
for (let row = 0; row < GRID_ROWS; row++) {
|
||||
for (let col = 0; col < GRID_COLS; col++) {
|
||||
const cx = boardX + (col + 0.5) * cellSize;
|
||||
const cy = boardY + (row + 0.5) * cellSize;
|
||||
holes.push({ x: cx, y: cy, r });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 在空闲洞里随机挑一个 index。 */
|
||||
function pickFreeHole() {
|
||||
const free = [];
|
||||
for (let i = 0; i < HOLES; i++) if (moles[i] === null) free.push(i);
|
||||
if (free.length === 0) return -1;
|
||||
return free[Math.floor(rng() * free.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 让指定 index 的地鼠开始冒头(纯逻辑数据,不画)。
|
||||
* @param {number} idx 洞 index
|
||||
* @param {boolean} [immediate] true=直接进入 stay 状态(完全冒头,popProgress=1)
|
||||
*/
|
||||
function spawnMoleAt(idx, immediate) {
|
||||
const h = holes[idx];
|
||||
if (!h) return;
|
||||
const nowMs = ctx ? ctx.time() : 0;
|
||||
let riseStartMs, riseMs, stayStartMs, retractStartMs, retractMs;
|
||||
let popProgress, state;
|
||||
if (immediate) {
|
||||
// 直接 stay:已完全冒头
|
||||
riseStartMs = nowMs - RISE_MS; // 已完成
|
||||
riseMs = RISE_MS;
|
||||
stayStartMs = nowMs;
|
||||
retractStartMs = nowMs + POP_LIFE_MS;
|
||||
retractMs = RETRACT_MS;
|
||||
popProgress = 1;
|
||||
state = 'stay';
|
||||
} else {
|
||||
riseStartMs = nowMs;
|
||||
riseMs = RISE_MS;
|
||||
stayStartMs = nowMs + riseMs;
|
||||
retractStartMs = nowMs + riseMs + POP_LIFE_MS;
|
||||
retractMs = RETRACT_MS;
|
||||
popProgress = 0;
|
||||
state = 'rising';
|
||||
}
|
||||
moles[idx] = {
|
||||
idx,
|
||||
cx: h.x,
|
||||
cy: h.y,
|
||||
r: h.r,
|
||||
riseStartMs,
|
||||
riseMs,
|
||||
stayStartMs,
|
||||
retractStartMs,
|
||||
retractMs,
|
||||
popProgress,
|
||||
state,
|
||||
// 命中后短暂"晕眩"动画再移除
|
||||
hit: false,
|
||||
hitMs: 0,
|
||||
};
|
||||
measures.popCount += 1;
|
||||
}
|
||||
|
||||
/** 调度下一只地鼠出现。 */
|
||||
function scheduleNextSpawn() {
|
||||
if (spawnTimer != null) timerScheduler.cancel(spawnTimer);
|
||||
const delay = Math.round(rngRange(POP_DELAY_MS_MIN, POP_DELAY_MS_MAX));
|
||||
spawnTimer = timerScheduler.after(delay, () => {
|
||||
spawnTimer = null;
|
||||
const i = pickFreeHole();
|
||||
if (i >= 0) spawnMoleAt(i, false);
|
||||
// 持续 spawn,直到场景切走(onExit 取消定时器)
|
||||
if (sceneFsm.current() === SCENE_PLAY) scheduleNextSpawn();
|
||||
});
|
||||
}
|
||||
|
||||
/** 让 idx 这只地鼠立即缩回(若还没被点中)。 */
|
||||
function retractMoleAt(idx, asMiss) {
|
||||
const m = moles[idx];
|
||||
if (!m || m.state === 'gone') return;
|
||||
// 已经在 hit 动画里的不记 miss
|
||||
if (!m.hit && asMiss) {
|
||||
missCount += 1;
|
||||
measures.misses += 1;
|
||||
combo = 0; // 漏了断 combo
|
||||
if (audioMusic) try { audioMusic.playSfx('fail'); } catch (_) {}
|
||||
}
|
||||
// 触发缩回:重置 retract 起始时间,以当前进度为起点
|
||||
const nowMs = ctx ? ctx.time() : 0;
|
||||
m.retractStartMs = nowMs;
|
||||
m.fullyRetractedMs = nowMs + m.retractMs;
|
||||
if (m.state !== 'hit') m.state = 'retracting';
|
||||
}
|
||||
|
||||
/** 立即清理某只地鼠。 */
|
||||
function disposeMole(idx) {
|
||||
moles[idx] = null;
|
||||
}
|
||||
|
||||
/** 进入 play。 */
|
||||
function enterPlay() {
|
||||
sessionScore.reset();
|
||||
missCount = 0;
|
||||
combo = 0;
|
||||
lastHitMs = -1e9;
|
||||
measures.hits = 0;
|
||||
measures.misses = 0;
|
||||
measures.popCount = 0;
|
||||
measures.score = 0;
|
||||
moles = new Array(HOLES).fill(null);
|
||||
if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; }
|
||||
computeHoles();
|
||||
playStartMs = ctx ? ctx.time() : 0;
|
||||
// 立即冒头 INITIAL_SPAWN_COUNT 只(都直接 stay)—— 让自动驱动第一时间有可点目标
|
||||
let spawned = 0;
|
||||
let attempts = 0;
|
||||
while (spawned < INITIAL_SPAWN_COUNT && attempts < HOLES) {
|
||||
const i = pickFreeHole();
|
||||
attempts += 1;
|
||||
if (i >= 0) {
|
||||
spawnMoleAt(i, true);
|
||||
spawned += 1;
|
||||
}
|
||||
}
|
||||
scheduleNextSpawn();
|
||||
sceneFsm.transition(SCENE_PLAY);
|
||||
if (ctx) ctx.log('scene', 'play 开始', { holes: HOLES, missLimit: MISS_LIMIT });
|
||||
}
|
||||
|
||||
/** 进入 over。 */
|
||||
function enterOver(reason) {
|
||||
if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; }
|
||||
sessionScore.lose();
|
||||
sceneFsm.transition(SCENE_OVER);
|
||||
if (ctx) ctx.log('scene', 'over 结算', { reason, score: sessionScore.getScore(), miss: missCount });
|
||||
}
|
||||
|
||||
/** 计算连击奖励分(命中基础 1 + 连击奖励)。 */
|
||||
function comboBonus() {
|
||||
if (combo < 2) return 0;
|
||||
// 每多一档 +1,封顶 +5
|
||||
return Math.min(5, combo - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 【输入入口·L3 只写这个】各 phase 的点击逻辑。
|
||||
* L1 在每次 pointerdown 时直达调用。
|
||||
*/
|
||||
function handleTap(x, y) {
|
||||
measures.taps += 1;
|
||||
const cur = sceneFsm.current();
|
||||
if (cur === SCENE_MENU) {
|
||||
if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();
|
||||
return;
|
||||
}
|
||||
if (cur === SCENE_OVER) {
|
||||
if (overRestartBtn && hudUi.pointInRect(x, y, overRestartBtn)) enterPlay();
|
||||
return;
|
||||
}
|
||||
if (cur === SCENE_PLAY) {
|
||||
// 命中判定:点中任一只可见地鼠(stay / rising 露出后 / hit 晕眩中)
|
||||
// 不挑剔状态,只要在洞附近就允分
|
||||
for (let i = 0; i < HOLES; i++) {
|
||||
const m = moles[i];
|
||||
if (!m) continue;
|
||||
if (m.hit) continue; // 已点过的忽略
|
||||
if (m.state === 'gone') continue;
|
||||
// 命中几何:用洞口圆(玩家直觉"洞口"=地鼠所在)
|
||||
if (hudUi.pointInCircle(x, y, m.cx, m.cy, m.r)) {
|
||||
// 计分:基础 1 + 连击奖励
|
||||
const nowMs = ctx ? ctx.time() : 0;
|
||||
if (nowMs - lastHitMs <= COMBO_WINDOW_MS) combo += 1;
|
||||
else combo = 1;
|
||||
lastHitMs = nowMs;
|
||||
const bonus = comboBonus();
|
||||
const gained = 1 + bonus;
|
||||
sessionScore.addScore(gained);
|
||||
measures.hits += 1;
|
||||
measures.score = sessionScore.getScore();
|
||||
m.hit = true;
|
||||
m.hitMs = nowMs;
|
||||
m.state = 'hit';
|
||||
// juice:小爆裂 + 命中停帧
|
||||
if (juice) {
|
||||
try {
|
||||
juice.burst(m.cx, m.cy);
|
||||
juice.flashScreen(0.05, 0.18);
|
||||
juice.shakeScreen(0.06, 3);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (audioMusic) try { audioMusic.playSfx('hit'); } catch (_) {}
|
||||
if (ctx) ctx.log('hit', '点中地鼠', { idx: i, gained, score: measures.score, combo });
|
||||
// 200ms 后清掉这只
|
||||
timerScheduler.after(200, () => disposeMole(i));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册 3 个场景。 */
|
||||
function defineScenes() {
|
||||
sceneFsm.define(SCENE_MENU, {
|
||||
onEnter() {
|
||||
moles = new Array(HOLES).fill(null);
|
||||
if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; }
|
||||
missCount = 0;
|
||||
combo = 0;
|
||||
},
|
||||
render(g) { menuStartBtn = renderMenu(g, hudUi, VW, VH).startBtn; },
|
||||
});
|
||||
sceneFsm.define(SCENE_PLAY, {
|
||||
render(g) {
|
||||
renderPlay(g, hudUi, {
|
||||
boardRect, holes,
|
||||
moles,
|
||||
score: sessionScore.getScore(),
|
||||
combo,
|
||||
hits: measures.hits,
|
||||
missCount,
|
||||
missLimit: MISS_LIMIT,
|
||||
timeMs: ctx ? ctx.time() : 0,
|
||||
VW, VH,
|
||||
});
|
||||
},
|
||||
});
|
||||
sceneFsm.define(SCENE_OVER, {
|
||||
onEnter() {
|
||||
moles = new Array(HOLES).fill(null);
|
||||
if (spawnTimer != null) { timerScheduler.cancel(spawnTimer); spawnTimer = null; }
|
||||
},
|
||||
render(g) {
|
||||
overRestartBtn = renderOver(g, hudUi, {
|
||||
score: sessionScore.getScore(),
|
||||
best: sessionScore.getBest(),
|
||||
missCount,
|
||||
missLimit: MISS_LIMIT,
|
||||
VW, VH,
|
||||
}).restartBtn;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════ GameInstance 五法 ════════════ */
|
||||
const instance = {
|
||||
async init(b) {
|
||||
boot = b;
|
||||
ctx = b.ctx;
|
||||
assets = await loadAssets('./assets/');
|
||||
// 预绑定音效(让 host 能容错降级时仍工作)
|
||||
if (audioMusic) {
|
||||
try { audioMusic.playSfx('hit'); } catch (_) {} // noop,预热路径
|
||||
}
|
||||
defineScenes();
|
||||
sceneFsm.start(SCENE_MENU);
|
||||
inited = true;
|
||||
if (ctx) ctx.log('init', 'game-logic 就绪 menu', { vw: VW, vh: VH, holes: HOLES });
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
if (!inited) return;
|
||||
measures.frames += 1;
|
||||
if (bundle && typeof bundle.tick === 'function') bundle.tick(dt); // ★
|
||||
sceneFsm.update(dt);
|
||||
|
||||
if (sceneFsm.current() !== SCENE_PLAY) return;
|
||||
|
||||
const nowMs = ctx ? ctx.time() : 0;
|
||||
// 推进每只地鼠的状态 + 处理缩回 → miss
|
||||
for (let i = 0; i < HOLES; i++) {
|
||||
const m = moles[i];
|
||||
if (!m) continue;
|
||||
if (m.hit) {
|
||||
// hit 动画中,等 200ms 后被 disposeMole
|
||||
if (nowMs - m.hitMs > 200) disposeMole(i);
|
||||
continue;
|
||||
}
|
||||
if (m.state === 'rising') {
|
||||
const t = (nowMs - m.riseStartMs) / m.riseMs;
|
||||
m.popProgress = Math.min(1, Math.max(0, t));
|
||||
if (t >= 1) m.state = 'stay';
|
||||
} else if (m.state === 'stay') {
|
||||
if (nowMs >= m.retractStartMs) {
|
||||
// 缩回触发 = miss(只在没人点的情况下)
|
||||
retractMoleAt(i, true);
|
||||
}
|
||||
} else if (m.state === 'retracting') {
|
||||
const t = (nowMs - m.retractStartMs) / m.retractMs;
|
||||
m.popProgress = Math.max(0, 1 - t);
|
||||
if (t >= 1) {
|
||||
disposeMole(i);
|
||||
// miss 计数在 retractMoleAt 时已加;这里检查终局
|
||||
if (missCount >= MISS_LIMIT) {
|
||||
if (audioMusic) try { audioMusic.playSfx('fail'); } catch (_) {}
|
||||
enterOver('miss_limit');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// miss 满了 → 结束(也在这里兜底:stay 状态到期瞬间 missCount 已加)
|
||||
if (missCount >= MISS_LIMIT) {
|
||||
if (audioMusic) try { audioMusic.playSfx('fail'); } catch (_) {}
|
||||
enterOver('miss_limit');
|
||||
}
|
||||
},
|
||||
|
||||
render(g) {
|
||||
if (!inited) return;
|
||||
sceneFsm.render(g);
|
||||
// juice 套件(闪白 / 屏震偏移 / 脉冲缩放)由插件自渲染;若它内部有 render 链则由 plugins 提供
|
||||
// 本款不调 juice.render —— 闪白与屏震由 renderPlay 自行读 getFlashAlpha/getShakeOffset 叠加(详见 render.js)
|
||||
},
|
||||
|
||||
handleTap,
|
||||
|
||||
destroy() {
|
||||
if (spawnTimer != null) { try { timerScheduler.cancel(spawnTimer); } catch (_) {} spawnTimer = null; }
|
||||
try { sceneFsm.reset(); } catch (_) {}
|
||||
inited = false;
|
||||
},
|
||||
|
||||
/** 取证视图:自动验收据此验「真活 + 有进展」。
|
||||
* state() 在 **所有 phase** 都返回 targets ——
|
||||
* · menu 阶段:开始按钮(让 ensurePlaySpec 推断为 tap-targets driver)
|
||||
* · play 阶段:当前冒头地鼠(让 tap-targets driver 能点中)
|
||||
* · over 阶段:重开按钮(让 driver 看到终态仍有点可点 → phase 驻留 latch)
|
||||
*/
|
||||
_forensicsView() {
|
||||
return {
|
||||
state: () => {
|
||||
const cur = sceneFsm.current();
|
||||
const targets = [];
|
||||
if (cur === SCENE_MENU) {
|
||||
// menu:开始按钮(中线点)
|
||||
if (menuStartBtn) {
|
||||
targets.push({
|
||||
x: Math.round(menuStartBtn.x + menuStartBtn.w / 2),
|
||||
y: Math.round(menuStartBtn.y + menuStartBtn.h / 2),
|
||||
occupied: true,
|
||||
role: 'start',
|
||||
});
|
||||
}
|
||||
} else if (cur === SCENE_OVER) {
|
||||
// over:重开按钮
|
||||
if (overRestartBtn) {
|
||||
targets.push({
|
||||
x: Math.round(overRestartBtn.x + overRestartBtn.w / 2),
|
||||
y: Math.round(overRestartBtn.y + overRestartBtn.h / 2),
|
||||
occupied: true,
|
||||
role: 'restart',
|
||||
});
|
||||
}
|
||||
} else if (cur === SCENE_PLAY) {
|
||||
// play:列出所有当前可见地鼠(state=stay/rising/retracting 任意),
|
||||
// 且未 hit 未 gone 的,作为 occupied:true 让自动驱动直接可点
|
||||
for (let i = 0; i < HOLES; i++) {
|
||||
const m = moles[i];
|
||||
if (!m) continue;
|
||||
if (m.hit) continue;
|
||||
if (m.state === 'gone') continue;
|
||||
// 任何可见地鼠都算可点(闭眼乱点必漏 → miss 累加 → over)
|
||||
targets.push({
|
||||
x: Math.round(m.cx),
|
||||
y: Math.round(m.cy),
|
||||
occupied: true,
|
||||
idx: i,
|
||||
state: m.state,
|
||||
pop: m.popProgress,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
phase: cur,
|
||||
score: sessionScore.getScore(),
|
||||
best: sessionScore.getBest(),
|
||||
outcome: sessionScore.getOutcome(),
|
||||
missCount,
|
||||
missLimit: MISS_LIMIT,
|
||||
combo,
|
||||
holes: holes.map((h) => ({ x: Math.round(h.x), y: Math.round(h.y), r: Math.round(h.r) })),
|
||||
targets,
|
||||
pendingTimers: timerScheduler.pending(),
|
||||
};
|
||||
},
|
||||
measures: () => ({ ...measures }),
|
||||
};
|
||||
},
|
||||
|
||||
/** 确定性命令入口(host.do)。 */
|
||||
_handleAction(action) {
|
||||
if (!action || !inited) return;
|
||||
const cur = sceneFsm.current();
|
||||
if (action.type === 'start' && cur === SCENE_MENU) enterPlay();
|
||||
else if (action.type === 'restart' && cur === SCENE_OVER) enterPlay();
|
||||
else if (action.type === 'hitFirst' || action.type === 'hitAny') {
|
||||
// 给 e2e 用:点中第一只当前可见地鼠
|
||||
if (cur === SCENE_PLAY) {
|
||||
for (let i = 0; i < HOLES; i++) {
|
||||
const m = moles[i];
|
||||
if (m && !m.hit && m.state !== 'gone') {
|
||||
handleTap(m.cx, m.cy);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// 固定步长导出(测试/宿主对齐用)。
|
||||
export { FIXED_DT };
|
||||
73
contracts/gate-fixtures/products/amgen-fx4c-4/src/game.js
Normal file
73
contracts/gate-fixtures/products/amgen-fx4c-4/src/game.js
Normal 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';
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。
|
||||
* ① 摊平 opts.runtime → 交 game-logic 的 createGame({plugins,bundle,viewport});
|
||||
* ② 接管「输入→handleTap 派发」这一固定 plumbing(根治 B):init 后订阅受控输入,
|
||||
* pointerdown 一律直达实例方法 handleTap(x,y)、keydown 直达 handleKey(key)。
|
||||
* —— L3 的 game-logic.js **只写** handleTap/handleKey 的各 phase 逻辑,**绝不自己订阅输入**
|
||||
* (不写 ctx.getInput、不自管 pendingClicks 队列、不在 update 里挑时机消费点击)。
|
||||
* 结构性根除「点击在错的 phase 分支被消费 / 永不消费 → 游戏启动不了」这类 M3 易犯错。
|
||||
* @param {Object} [opts] 工厂选项(host wiring)。
|
||||
* @returns {import('../../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export default function gameFactory(opts) {
|
||||
const runtime = (opts && opts.runtime) || {};
|
||||
// L3 游戏本体实例(game-logic.js 的 createGame;扁平入参,无 opts.runtime 嵌套坑)。
|
||||
const game = createGameLogic({
|
||||
plugins: runtime.plugins || {},
|
||||
bundle: runtime.bundle,
|
||||
viewport: runtime.viewport || { w: 390, h: 844 }, // viewport 由 L1 host-config 下传;兜底 390×844
|
||||
});
|
||||
|
||||
/* ── L1 输入 plumbing:包装 init/destroy,把「受控输入→实例 handleTap/handleKey」收归固定层 ── */
|
||||
const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null;
|
||||
const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null;
|
||||
/** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */
|
||||
let inputSubs = [];
|
||||
|
||||
game.init = async function init(boot) {
|
||||
if (innerInit) await innerInit(boot); // 先让 L3 装资产 / 定义场景 / start(menu)
|
||||
const ctx = boot && boot.ctx;
|
||||
const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null;
|
||||
if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩)
|
||||
// pointerdown → handleTap(x,y):无论游戏当前在哪个 phase 都直达,菜单/结算/玩中分支由 handleTap 内部自行判定。
|
||||
if (typeof game.handleTap === 'function') {
|
||||
inputSubs.push(input.on('pointerdown', (e) => {
|
||||
try { game.handleTap((e && e.x) || 0, (e && e.y) || 0); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleTap 抛错(已隔离)', { msg: err && err.message }); } // 错误隔离:不连坐 boot/后续帧
|
||||
}));
|
||||
}
|
||||
// keydown → handleKey(key):方向键 / 空格类玩法用(可选;游戏写了才接)。
|
||||
if (typeof game.handleKey === 'function') {
|
||||
inputSubs.push(input.on('keydown', (e) => {
|
||||
try { game.handleKey((e && e.key) || ''); }
|
||||
catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
game.destroy = function destroy() {
|
||||
for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } }
|
||||
inputSubs = [];
|
||||
if (innerDestroy) innerDestroy();
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
110
contracts/gate-fixtures/products/amgen-fx4c-4/src/host-config.js
Normal file
110
contracts/gate-fixtures/products/amgen-fx4c-4/src/host-config.js
Normal 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 不硬编码
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
254
contracts/gate-fixtures/products/amgen-fx4c-4/src/render.js
Normal file
254
contracts/gate-fixtures/products/amgen-fx4c-4/src/render.js
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user