U1 展开(plan 002):证适配器不止 tap-targets,泛化到 realtime/update 行为。 - 加 trigger=update 词汇 op=drift-targets(目标随 ctx.time 慢漂移,amplitude/speed 可配);targets 记 baseX/baseY/phase。 - 新 fixture moving-targets.source.json(tickModel=realtime + drift behavior + tap behavior + win rule)。 - drift 单测 +2(共 8):update 后目标漂移 / 漂移下 tap 当前位仍命中计分。单测 11/11 绿(adapter 8 + scaffold 3)。 - e2e(集成段,本机):scaffoldFromSource 落 moving-targets → build → 真九门 A–I + 首局门全过 (tap driver 命中漂移目标、score 0→3、latch 驻留;瞬时验证件已清)。 证成:两迥异 archetype(静态点击 + realtime 漂移)经同一适配器都过真九门 → 退役 adversarial F4 「声明式最小集过不了九门」之虑;E1 声明式路泛化成立。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
'use strict';
|
||
/**
|
||
* create-game-from-definition.js —— E1 2D 适配器(U1 最小贯穿实验切片)
|
||
* owner:前端+引擎线(Mac)|计划:docs/plans/2026-06-17-002-feat-mac-engine-frontend-tier0-plan.md U1
|
||
*
|
||
* 【做什么】把固定架构的声明式 `SourceProject.gameDefinition`(contracts/agent-loop/source-project.schema.json)
|
||
* 适配成命中 game-host.d.ts `GameHostFactory`→`GameInstance` 契约的可玩实例:
|
||
* entities → 渲染对象 · components(render) → 画法 · behaviors → 逻辑 · scenes → 关卡构成 · rules → 胜负 · config → 平衡参数。
|
||
*
|
||
* 【边界(KTD1,跨线已锁方向)】本切片是**声明式解释器**:解释 entities/components/scenes/rules/config
|
||
* + 一个**最小 behavior 词汇**(够单 behavior 游戏端到端过九门)。复杂 behavior 的真逻辑由 code agent 产、
|
||
* 经 build.mjs 编进 __GameBundle(非本适配器解释)——本切片只证「声明式路」端到端可行(接缝形态验证)。
|
||
*
|
||
* 【受控面铁律】零引擎 import;能力一律经 boot.ctx(getEngine 可能为 null,须容错);render 只画到入参 g(=boot.mainContext);
|
||
* 输入坐标 = 逻辑像素 x∈[0,390]/y∈[0,844];随机/时钟走 ctx.random/ctx.time(禁 Math.random/Date.now)。
|
||
*/
|
||
|
||
const VIEWPORT_W = 390;
|
||
const VIEWPORT_H = 844;
|
||
|
||
/** 最小 behavior 词汇表(声明式路支持的 op;其余 op = 走 code-agent 逻辑码,本切片不解释)。 */
|
||
const SUPPORTED_INPUT_OPS = new Set(['hit-nearest-target']);
|
||
/** trigger=update 的 op(本切片支持 drift-targets:目标随时间慢漂移,证 realtime/update behavior 路泛化)。 */
|
||
const SUPPORTED_UPDATE_OPS = new Set(['drift-targets']);
|
||
|
||
/**
|
||
* 极小安全条件求值(无 eval):解析 "<field> <op> <rhs>"。
|
||
* field ∈ state 数值字段(如 score);op ∈ >= > == <= <;rhs = 数字字面量 或 "config.<key>"。
|
||
* @returns {boolean} 解析失败/字段缺失 → false(不抛,胜负条件保守不触发)。
|
||
*/
|
||
function evalCondition(cond, state, config) {
|
||
if (typeof cond !== 'string') return false;
|
||
const parts = cond.trim().split(/\s+/);
|
||
if (parts.length !== 3) return false;
|
||
const [lhs, op, rhsRaw] = parts;
|
||
const lhsVal = state[lhs];
|
||
if (typeof lhsVal !== 'number') return false;
|
||
const rhsVal = rhsRaw.startsWith('config.') ? config[rhsRaw.slice('config.'.length)] : Number(rhsRaw);
|
||
if (typeof rhsVal !== 'number' || Number.isNaN(rhsVal)) return false;
|
||
switch (op) {
|
||
case '>=': return lhsVal >= rhsVal;
|
||
case '>': return lhsVal > rhsVal;
|
||
case '==': return lhsVal === rhsVal;
|
||
case '<=': return lhsVal <= rhsVal;
|
||
case '<': return lhsVal < rhsVal;
|
||
default: return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 适配 SourceProject(或裸 gameDefinition)→ GameHostFactory。
|
||
* @param {object} source SourceProject(含 .gameDefinition/.config/.assets/.profile)或裸 gameDefinition。
|
||
* @returns {(opts?: object) => import('../core/game-host.d.ts').GameInstance} GameHostFactory
|
||
*/
|
||
export function adaptGameDefinition(source) {
|
||
const src = source || {};
|
||
// 兼容:传整 SourceProject 取 .gameDefinition;传裸 gameDefinition 直接用。
|
||
const gd = src.gameDefinition || src || {};
|
||
const config = src.config || gd.config || {};
|
||
const profile = src.profile || {};
|
||
const entities = Array.isArray(gd.entities) ? gd.entities : [];
|
||
const components = Array.isArray(gd.components) ? gd.components : [];
|
||
const scenes = Array.isArray(gd.scenes) ? gd.scenes : [];
|
||
const rules = Array.isArray(gd.rules) ? gd.rules : [];
|
||
const behaviors = Array.isArray(gd.behaviors) ? gd.behaviors : [];
|
||
|
||
const compById = new Map(components.map((c) => [c.id, c]));
|
||
// 激活场景 = 首个 scene(无则全实体);激活实体按 entityRefs 过滤。
|
||
const activeScene = scenes[0];
|
||
const activeIds = activeScene && Array.isArray(activeScene.entityRefs)
|
||
? new Set(activeScene.entityRefs)
|
||
: null;
|
||
const activeEntities = activeIds ? entities.filter((e) => activeIds.has(e.id)) : entities;
|
||
|
||
// 预解析每个实体的 render 组件(声明式画法)。
|
||
function renderCompOf(entity) {
|
||
const ids = Array.isArray(entity.components) ? entity.components : [];
|
||
for (const cid of ids) {
|
||
const c = compById.get(cid);
|
||
if (c && c.kind === 'render') return c;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
return function createGame(/* opts */) {
|
||
let ctx = null;
|
||
let sub = null;
|
||
let nowMs = 0;
|
||
// 运行态:目标(可点击实体)+ 计分 + phase(booting|playing|gameover,对齐 P0 latch 终态)。
|
||
/** @type {{id:string,x:number,y:number,w:number,h:number,color:string,hit:boolean}[]} */
|
||
let targets = [];
|
||
const state = { phase: 'booting', score: 0, result: null, t: 0 };
|
||
|
||
/** init:建目标、订阅受控输入、置 playing。 */
|
||
function init(boot) {
|
||
ctx = boot.ctx;
|
||
targets = activeEntities.map((e, i) => {
|
||
const rc = renderCompOf(e) || {};
|
||
const pos = (e.transform && e.transform.position) || { x: 0, y: 0 };
|
||
return {
|
||
id: e.id,
|
||
x: pos.x, y: pos.y,
|
||
baseX: pos.x, baseY: pos.y, phase: i * 1.7, // drift-targets 用:漂移基准 + 错相
|
||
w: typeof rc.w === 'number' ? rc.w : 48,
|
||
h: typeof rc.h === 'number' ? rc.h : 48,
|
||
color: typeof rc.color === 'string' ? rc.color : '#3cf',
|
||
hit: false,
|
||
};
|
||
});
|
||
sub = ctx.getInput().on('pointerdown', onTap); // 受控订阅(禁 addEventListener)
|
||
state.phase = 'playing';
|
||
}
|
||
|
||
/** 输入:解释 trigger=input 的 behavior(本切片支持 op=hit-nearest-target)。 */
|
||
function onTap(e) {
|
||
if (state.phase !== 'playing') return; // latch:终态后输入无效
|
||
nowMs = ctx.time.nowMs();
|
||
const px = e.x || 0, py = e.y || 0; // 已是逻辑像素,勿再乘视口
|
||
for (const b of behaviors) {
|
||
if (b.trigger !== 'input' || !SUPPORTED_INPUT_OPS.has(b.op)) continue;
|
||
if (b.op === 'hit-nearest-target') {
|
||
const radius = typeof config.hitRadius === 'number' ? config.hitRadius : 60;
|
||
let best = null, bestD2 = radius * radius;
|
||
for (const tg of targets) {
|
||
if (tg.hit) continue;
|
||
const cx = tg.x + tg.w / 2, cy = tg.y + tg.h / 2;
|
||
const d2 = (cx - px) * (cx - px) + (cy - py) * (cy - py);
|
||
if (d2 <= bestD2) { best = tg; bestD2 = d2; }
|
||
}
|
||
if (best) {
|
||
best.hit = true;
|
||
state.score += 1;
|
||
// 命中反馈(game-feel + 满足九门 F 真接线):引擎粒子+音效,经 ctx.getEngine 容错(null 不连坐)。
|
||
const eng = ctx.getEngine && ctx.getEngine();
|
||
if (eng) {
|
||
const cx = best.x + best.w / 2, cy = best.y + best.h / 2;
|
||
if (eng.particles) {
|
||
try { eng.particles.spawnEmitter({ pos: { x: cx, y: cy }, count: 20, speed: 120, particleTime: 0.5,
|
||
colorStart: { r: 1, g: 0.8, b: 0.2, a: 1 }, colorEnd: { r: 1, g: 0.3, b: 0, a: 0 } }); } catch (_) { /* 引擎缺省不连坐 */ }
|
||
}
|
||
if (eng.audio && eng.audio.synth) {
|
||
try { eng.audio.synth.synthSfx([1, 0.05, 260, , , 0.15, , 1.3]); } catch (_) { /* 同上 */ }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** update:刷钟 + 评估 rules(胜负 → phase=gameover + 终态驻留)。 */
|
||
function update(dt) {
|
||
if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; }
|
||
if (state.phase !== 'playing') return; // 已终态:latch 驻留,不再评估
|
||
// trigger=update 行为(本切片:drift-targets 目标慢漂移,证 realtime/update 路)。
|
||
for (const b of behaviors) {
|
||
if (b.trigger !== 'update' || !SUPPORTED_UPDATE_OPS.has(b.op)) continue;
|
||
if (b.op === 'drift-targets') {
|
||
const amp = typeof b.amplitude === 'number' ? b.amplitude : 24;
|
||
const spd = typeof b.speed === 'number' ? b.speed : 0.8;
|
||
for (const tg of targets) {
|
||
tg.x = tg.baseX + amp * Math.sin(state.t * spd + tg.phase); // 慢漂移:driver 逐步读 _forensicsView 仍可命中
|
||
}
|
||
}
|
||
}
|
||
for (const r of rules) {
|
||
if (evalCondition(r.condition, state, config)) {
|
||
if (r.outcome === 'win' || r.outcome === 'lose') {
|
||
state.phase = 'gameover';
|
||
state.result = r.outcome;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** render:画到入参 g(=mainContext)。满幅底(D 门)+ 目标(命中变暗)+ 计分 HUD + 时间脉冲(E 门活性)+ 终态字。 */
|
||
function render(g) {
|
||
g.fillStyle = '#0d1117';
|
||
g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H);
|
||
// 时间脉冲条:保证无输入时帧间也有变化(E 门活性,走 ctx.time 非 Date.now)。
|
||
const pulse = 0.5 + 0.5 * Math.sin(state.t * 2);
|
||
g.fillStyle = 'rgba(80,160,255,' + (0.25 + 0.5 * pulse).toFixed(3) + ')';
|
||
g.fillRect(0, 0, VIEWPORT_W, 6);
|
||
for (const tg of targets) {
|
||
g.fillStyle = tg.hit ? '#2a3340' : tg.color;
|
||
g.fillRect(tg.x, tg.y, tg.w, tg.h);
|
||
}
|
||
const winScore = typeof config.winScore === 'number' ? config.winScore : targets.length;
|
||
g.fillStyle = '#cdd9e5';
|
||
g.font = '20px monospace';
|
||
g.fillText('Score: ' + state.score + '/' + winScore, 20, 40);
|
||
if (state.phase === 'gameover') {
|
||
g.fillStyle = state.result === 'win' ? '#4ade80' : '#f87171';
|
||
g.font = '32px monospace';
|
||
g.fillText(state.result === 'win' ? 'YOU WIN' : 'GAME OVER', 110, 430);
|
||
}
|
||
}
|
||
|
||
/** 取证视图:契约 = { seed?, state:()=>snapshot }(boot-game-host.js:283 调 `_forensicsView().state()`;参照 tictactoe)。
|
||
* harness 九门(H 进展/latch)+ tap-targets driver 经 host.state() 读 snapshot。 */
|
||
function _forensicsView() {
|
||
return {
|
||
seed: 1,
|
||
state: () => {
|
||
const ws = typeof config.winScore === 'number' ? config.winScore : targets.length;
|
||
return {
|
||
phase: state.phase,
|
||
score: state.score,
|
||
result: state.result,
|
||
progress: ws ? state.score / ws : 0,
|
||
tickModel: profile.tickModel || null,
|
||
// tap-targets driver 读 targets[{idx,x,y,occupied}] 自适应出招(逻辑坐标=中心点)。
|
||
targets: targets.map((tg, idx) => ({
|
||
idx, x: tg.x + tg.w / 2, y: tg.y + tg.h / 2, occupied: tg.hit,
|
||
})),
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
/** destroy:幂等卸载(取消订阅)。 */
|
||
function destroy() {
|
||
try { sub && sub.cancel(); } catch (_) { /* 幂等 */ }
|
||
sub = null;
|
||
targets = [];
|
||
}
|
||
|
||
return { init, update, render, _forensicsView, destroy };
|
||
};
|
||
}
|