feat(cheap-worker): 非遗品类件③few-shot正例+rubric金标锚定回填+骨架import护栏 (W-GENRE)

③ game-runtime/games/_fewshot-feiyi/:few-shot 过门正例(木版年画作坊,源=n=5 小批第 3
款 p11d-print):attempts=1/¥2.65/九门 A-I 全绿/丰富度 9-11;范例签名机器校验门
(tools.mjs check)蒸馏时复跑 PASS;README 带来历/复验配方/漂移纪律(设计档 §3⑥
「必须同改」清单成员)。
④ 锚定回填 heritage.rubric.json:通用 11+品类 5 合评、两分母分开(M3 judge,容差
±1/组)——正例 通用 10/11(L2 6/6·L3 2/3·L4 2/2)+品类 5/5;薄反例(骨架原样)
通用 6/11+品类 3/5(节律张力/技艺成长 false=骨架无 combo/解锁,判罚与轻起点定位
一致)——评分尺判别力坐实。
② 骨架 game-logic.js 头注加 import 完整性护栏:n=5 首款 p11d-porc 失败根因 =
agent 改写时 import 列表漏 FIRST_READY_DELAY_MS、裸标识符 esbuild 当全局放行、
运行时点开工即 ReferenceError 被 L1 隔离→永卡菜单、E/G/H 三挂(check/build 双门
拦不住的盲区;node headless 探针定位)。护栏注释进克隆起点后 3 连过佐证。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-02 23:13:11 -07:00
parent c18e7f5324
commit 39cc2c09ba
7 changed files with 1009 additions and 3 deletions

View File

@ -45,8 +45,17 @@
],
"groupMaxes": { "L2": 3, "L3": 1, "L4": 1 },
"anchors": {
"_note": "金标锚定(SoT §4 规范三:≥1 正例 + ≥1 薄反例;rubric 文本/评分 prompt 变更须金标复验,单款分组小计漂移 >±1 回退)。待首次金标复验回填。",
"positive": null,
"thinNegative": null
"_note": "金标锚定(SoT §4 规范三:≥1 正例 + ≥1 薄反例;rubric 文本/评分 prompt 变更须金标复验,单款分组小计漂移 >±1 回退)。口径 = 通用底座 11 条 + 本品类扩展 5 条一次合评、两分母分开(MiniMax-M3 judge,单次锚定、容差 ±1/组);2026-07-02 首评回填。",
"positive": {
"sample": "games/_fewshot-feiyi(源=生成局 p11d-print,木版年画作坊)",
"base": { "score": 10, "max": 11, "groups": { "L2": { "score": 6, "max": 6 }, "L3": { "score": 2, "max": 3 }, "L4": { "score": 2, "max": 2 } } },
"genre": { "score": 5, "max": 5, "groups": { "L2": { "score": 3, "max": 3 }, "L3": { "score": 1, "max": 1 }, "L4": { "score": 1, "max": 1 } } }
},
"thinNegative": {
"sample": "games/_template-feiyi 骨架原样实例(轻起点,天然薄)",
"base": { "score": 6, "max": 11, "groups": { "L2": { "score": 4, "max": 6 }, "L3": { "score": 0, "max": 3 }, "L4": { "score": 2, "max": 2 } } },
"genre": { "score": 3, "max": 5, "groups": { "L2": { "score": 2, "max": 3 }, "L3": { "score": 0, "max": 1 }, "L4": { "score": 1, "max": 1 } },
"perItem": { "工序链成立": true, "节律张力": false, "技艺成长可见": false, "作品可展示": true, "文化氛围一致": true } }
}
}
}

View File

@ -0,0 +1,33 @@
# _fewshot-feiyi — 非遗品类 few-shot 过门正例(木版年画作坊)
这是非遗品类的 **few-shot 完整正例**(W-GENRE 四件套之③):一款由生成 agent 在 `_template-feiyi` 工序节拍骨架上做出来、**真过九门**的完整游戏,示范"轻骨架之上把 brief 做丰富"长什么样。生成 agent 需要完整范例时 read_file 这里的 `src/game-logic.js`(prompt 先读手册第 6 步与 `.agents/skills/heritage-game-design.md` §8 都指向此处)。
## 来历(可复核)
- **brief**:「木版年画作坊:刻版、上墨、铺纸、拓印,赶在年前印出最多年画;印得多解锁新的吉祥图样」。
- **生成局**:`p11d-print`(便宜档进程内路,MiniMax-M3,attempts=1 一次成型,¥2.65,335s;W-GENRE 非遗品类件 n=5 小批第 3 款)。
- **九门**:A–I 全绿(verdict = `games/_wg1-gen/p11d-print/evidence/verdict.json`,生成机器留档;批产记录见品类件工单回执)。
- **丰富度**(纯 LLM 验证 agent,非阻塞):9/11——L2 5/6 / L3 2/3 / L4 2/2;未命中仅「情感锚 / 放置回归」。
- **范例签名机器校验门**:`tools.mjs check` PASS(API 静态门 / 形状门 / 红线全绿;蒸馏时复跑过)。
## 它示范了什么(读 game-logic.js 时看这些)
- **换手艺不重写循环**:工序链(刻版→上墨→铺纸→拓印)与作品表(福字/门神/财神/麒麟/童子抱鱼)全落在 core.js 参数空间;工序节拍结构(就绪→窗口内点击→出成品→回炉)原样保留。
- **在循环上加机制**:正窗暴击 + 连击、攒工钱解锁新图样(本品类成长轴 = 工艺步骤/图样解锁)、结算屏摆出本局印成的年画(炫耀时刻)。
- **取证契约不破**:`_forensicsView().state()` 的 `targets`(occupied=当前就绪工位)与 `score`(单调上升)语义未动——这是过九门的命脉。
## 复验配方(怀疑漂移时跑)
```bash
# 在 game-runtime/ 下:骨架 clone 一个临时局,覆盖本正例 L3,过机器门 + 九门
node tools/amodel-gen/tools.mjs scaffold-saa p11d-fewshot-recheck _template-feiyi
cp games/_fewshot-feiyi/src/*.js games/amgen-p11d-fewshot-recheck/src/
node -e "import('./tools/amodel-gen/tools.mjs').then(m=>process.exit(m.check('p11d-fewshot-recheck').ok?0:1))"
node tools/amodel-gen/tools.mjs build-saa p11d-fewshot-recheck && node tools/amodel-gen/tools.mjs stage p11d-fewshot-recheck
cp ../cheap-worker/fixtures/golden-specs/heritage-craft.play-spec.json games/_wg1-gen/p11d-fewshot-recheck/play-spec.json
bash games/_wg1-gen/_shared/serve-and-play.sh p11d-fewshot-recheck
```
## 漂移纪律(设计档 §3⑥/§6:per-genre 正例是「必须同改」清单成员)
L2 插件 API 改签名时,本正例随 `_template-feiyi`、prompt、skill **同批同改**并复跑上面的复验配方——正例签名与现契约不一致 = 下一代脏数据源,宁可删除不可留漂。

View File

@ -0,0 +1,73 @@
/**
* assets.js — 资产装载器(宿主层:允许浏览器原生 Image/fetch;逻辑层 core.js 不得 import 本文件)
* owner:_template 克隆起点 | 消费方:game.js(init 时 await loadAssets)/ render.js(若用图则取)
*
* 【本款现状:无外部资产(纯程序化图形)】
* 「点圆得分」全用 g 原生绘制(圆/矩形/文字),不需任何 .jpg/.mp3,故 IMAGE_FILES 为空。
* 但**声明模式的结构必须留全**——这是克隆起点的意义:加美术时只需往 IMAGE_FILES 添键,
* render 里用 assets.images.<键> 取图,装载/容错链条已就位,不必重搭。
*
* 【容错铁律(发行版红线 §7-5)】
* 任一资产缺失/加载失败 → 该键置 null 并记入 missing,游戏照常启动(render 走程序化回退),
* 绝不因资产把游戏带崩。本款无资产 → loadAssets 容错返回空集(images:{}, missing:[], loaded:0, total:0)。
* node 环境无 Image → 同样安全返回空集(import-safe)。
* 音频文件不在此装载(需用户手势后解码,归 audio-director 懒加载;本款无音频)。
*/
'use strict';
/**
* 图像资产键 → 文件名(与 assets/manifest.json 对应)。
* 【在此声明图片资产键→文件名】本款无美术故为空对象 {}。
* 加图示例:`itemSoda: 'item-soda.jpg'`,然后 render 里用 `assets.images.itemSoda`(键名间接引用,不写死文件名)。
* @type {Record<string, string>}
*/
export const IMAGE_FILES = {};
/**
* 装载全部图像资产(并行;单个失败不连坐)。本款 IMAGE_FILES 为空 → 返回空集。
* @param {string} [baseUrl] 资产根(缺省 './assets/')
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
const images = {};
/** @type {string[]} */
const missing = [];
/** 装载单图(失败 resolve null,不 reject——容错降级)。@param {string} key */
function loadOne(key) {
return new Promise((resolve) => {
// 浏览器环境守卫:node 侧 import 本模块不应触发 Image(import-safe)。
if (typeof Image === 'undefined') {
images[key] = null;
missing.push(key);
resolve(null);
return;
}
const img = new Image();
img.onload = () => {
images[key] = img;
resolve(img);
};
img.onerror = () => {
// 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。
console.warn('[_template-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
images[key] = null;
missing.push(key);
resolve(null);
};
img.src = root + IMAGE_FILES[key];
});
}
// 空集时 Promise.all([]) 立即 resolve → 返回空容错结构(本款常态)。
return Promise.all(keys.map(loadOne)).then(() => ({
images,
missing,
loaded: keys.length - missing.length,
total: keys.length,
}));
}

View File

@ -0,0 +1,179 @@
/**
* core.js — 【L3 纯逻辑】木版年画作坊 · 工序节拍玩法数值与判定
*
* 本款 = 非遗「工序节拍」品类(template-feiyi)+ 传统技艺「木版年画」。
* 一件年画 = 一条 4 道工序链:刻版 → 上墨 → 铺纸 → 拓印;在火候窗口内点工位
* 完成该道工序;过窗回炉;整条链走完 = 印出一张年画;60s 限时赶工结算。
* 印满阈值解锁新图样(财神/麒麟/童子)——「印得多解锁新的吉祥图样」的核心钩子。
*
* 工序名取自木版年画通识工序词;**不编造具体非遗项目的失实细节**。
*
* 作品字段(公开契约,render / game-logic / 取证共用):
* work = { id, name, bonus, stepIndex, stepState:'crafting'|'ready', stateSinceMs,
* craftMs, windowMs, glyph: '福'|'门神'|... }
*/
'use strict';
/* ═══════════════════════════════════════════════════════════════════════════
* 【多样性参数空间 · 木版年画作坊】
* ═══════════════════════════════════════════════════════════════════════════ */
/** 一局开工时长(ms):年前赶工 60s。 */
export const ROUND_MS = 60000;
/**
* 工序链(木版年画 4 道):名字 + 颜色 + 完成分。
* 颜色用于程序化绘制工位物件(刻刀/墨辊/宣纸/棕刷的视觉锚)。
* 顺序:刻版→上墨→铺纸→拓印,越接近成品越值钱(后道高于前道)。
*/
export const CRAFT_CHAIN = [
{ name: '刻版', color: '#5a3a24', points: 5, tool: '刻刀', desc: '雕梨木板' },
{ name: '上墨', color: '#c8261a', points: 7, tool: '墨辊', desc: '朱砂上墨' },
{ name: '铺纸', color: '#f3e8c2', points: 9, tool: '宣纸', desc: '覆纸上版' },
{ name: '拓印', color: '#1f4e7a', points: 12, tool: '棕刷', desc: '按压转印' },
];
/**
* 作品表(吉祥图样):印出一张年画;bonus = 成品奖励分(≈单道工序分的 3–4 倍)。
* 收集向 = 凑齐一套解锁「图鉴」成就感。
*
* `unlockAtWage` 字段:达成该累计工钱后该图样解锁(传 -1 = 默认解锁)。
* 默认解锁 3 件,工钱达 80 解锁第 4 件「麒麟」,达 160 解锁第 5 件「童子」。
* ——「印得多解锁新的吉祥图样」的核心钩子落在这里。
*/
export const WORKS = [
{ name: '福字', glyph: '福', bgColor: '#c8261a', bonus: 26, unlockAtWage: 0 },
{ name: '门神', glyph: '门', bgColor: '#1f4e7a', bonus: 32, unlockAtWage: 0 },
{ name: '财神', glyph: '财', bgColor: '#caa84a', bonus: 38, unlockAtWage: 0 },
{ name: '麒麟', glyph: '麒', bgColor: '#5a3a24', bonus: 44, unlockAtWage: 80 },
{ name: '童子抱鱼', glyph: '童', bgColor: '#7a9b3e', bonus: 50, unlockAtWage: 160 },
];
/** 解锁的初始默认工资阈值(在本局内)——所有工资 < 阈值的图样在图鉴里置灰挂锁。 */
export const UNLOCK_PROGRESS_TOTAL = 200; // 「印得多解锁新的吉祥图样」封顶参考
/** 工序就绪间隔区间(ms):上一道完成后,下一道多久「就绪」。 */
export const CRAFT_MIN_MS = 650;
export const CRAFT_MAX_MS = 1050;
/** 开局首道工序就绪延迟(ms):留一拍让玩家看清案台。 */
export const FIRST_READY_DELAY_MS = 700;
/** 火候窗口区间(ms):工序就绪后须在窗口内点掉,过窗回炉(不判死)。 */
export const WINDOW_MIN_MS = 5000;
export const WINDOW_MAX_MS = 8000;
/** 正窗暴击(基础分之上加成):窗口前 40% 出手 → 加成倍率(双层奖励之技巧分)。 */
export const CRIT_RATIO = 0.4;
export const CRIT_MULT = 1.6;
/** 回炉冷却(ms):过窗后该道重新加工的耗时(损时间不清进度)。 */
export const REDO_MS = 1100;
/** 工位点按半径(手指友好,别小于 ~44)。 */
export const STATION_TAP_RADIUS = 58;
/** 工位布局比例(读 viewport 算具体像素,别硬编码 390×844):案台一横排。 */
export const STATION_ROW_Y_RATIO = [0.62];
export const STATION_MARGIN_X_RATIO = 0.14;
/* ═══════════════════════════════════════════════════════════════════════════
* 【工序循环判定 · 结构层 · 一般不动】
* ═══════════════════════════════════════════════════════════════════════════ */
/** 受控随机区间 [lo,hi):入参 rng 走 ctx.random,纯确定性。 */
function rngRange(rng, lo, hi) { return lo + rng() * (hi - lo); }
/**
* 算工位中心点(每道工序一个工位;读 viewport,不硬编码)。
* @returns {{x:number,y:number,station:number}[]} 与 CRAFT_CHAIN 等长
*/
export function computeStations(viewW, viewH, count) {
const n = count == null ? CRAFT_CHAIN.length : count;
const rows = STATION_ROW_Y_RATIO.length;
const perRow = Math.ceil(n / rows);
const left = viewW * STATION_MARGIN_X_RATIO;
const right = viewW * (1 - STATION_MARGIN_X_RATIO);
const out = [];
for (let i = 0; i < n; i++) {
const row = Math.floor(i / perRow);
const col = i % perRow;
const inRow = Math.min(perRow, n - row * perRow);
const x = inRow === 1 ? (left + right) / 2 : left + (col * (right - left)) / (inRow - 1);
out.push({ station: i, x: Math.round(x), y: Math.round(viewH * STATION_ROW_Y_RATIO[Math.min(row, rows - 1)]) });
}
return out;
}
/** 取当前工钱下可印的作品表(过滤未解锁的)。 */
export function availableWorks(wage, works) {
const list = works || WORKS;
return list.filter((w) => wage >= (w.unlockAtWage != null ? w.unlockAtWage : 0));
}
/** 开一件新作品(从已解锁图样中轮换)。 */
export function makeWork(rng, nowMs, idSeq, firstDelayMs, wage, works) {
const pool = availableWorks(wage, works);
const list = pool.length > 0 ? pool : (works || WORKS);
const w = list[idSeq % list.length];
return {
id: idSeq,
name: w.name,
glyph: w.glyph,
bgColor: w.bgColor,
bonus: w.bonus,
stepIndex: 0,
stepState: 'crafting',
stateSinceMs: nowMs,
craftMs: firstDelayMs != null ? firstDelayMs : Math.round(rngRange(rng, CRAFT_MIN_MS, CRAFT_MAX_MS)),
windowMs: Math.round(rngRange(rng, WINDOW_MIN_MS, WINDOW_MAX_MS)),
};
}
/** 加工态是否已到就绪时刻(crafting 满 craftMs → 该转 ready)。 */
export function shouldBecomeReady(work, nowMs) {
return !!work && work.stepState === 'crafting' && (nowMs - work.stateSinceMs) >= work.craftMs;
}
/** 就绪态是否已过火候窗口(ready 超 windowMs → 该回炉)。 */
export function isWindowExpired(work, nowMs) {
return !!work && work.stepState === 'ready' && (nowMs - work.stateSinceMs) > work.windowMs;
}
/** 火候窗口剩余比例 [0,1](1=刚就绪,0=已过窗;非 ready 返 0)。 */
export function windowRatio(work, nowMs) {
if (!work || work.stepState !== 'ready' || !work.windowMs) return 0;
const waited = nowMs - work.stateSinceMs;
return Math.max(0, Math.min(1, (work.windowMs - waited) / work.windowMs));
}
/** 当前工序此刻是否可点(= ready 且窗口未过)。 */
export function isStepTappable(work, nowMs) {
return !!work && work.stepState === 'ready' && (nowMs - work.stateSinceMs) <= work.windowMs;
}
/** 点 (px,py) 是否命中工位(圆心 + 半径)。 */
export function hitStation(px, py, st) {
if (!st) return false;
const dx = px - st.x, dy = py - st.y;
return dx * dx + dy * dy <= STATION_TAP_RADIUS * STATION_TAP_RADIUS;
}
/** 本道工序完成分(读 CRAFT_CHAIN;越界容错返 0)。 */
export function stepPoints(stepIndex) {
const s = CRAFT_CHAIN[stepIndex];
return s ? s.points : 0;
}
/** 是否为正窗暴击(在窗口前 CRIT_RATIO 比例内出手)。 */
export function isCritWindow(work, nowMs) {
if (!work || work.stepState !== 'ready' || !work.windowMs) return false;
const waited = nowMs - work.stateSinceMs;
return waited <= work.windowMs * CRIT_RATIO;
}
/** 解锁进度(0..1)——本局累计工资 / UNLOCK_PROGRESS_TOTAL,供图鉴用。 */
export function unlockProgress(wage) {
if (UNLOCK_PROGRESS_TOTAL <= 0) return 1;
return Math.max(0, Math.min(1, wage / UNLOCK_PROGRESS_TOTAL));
}

View File

@ -0,0 +1,331 @@
/**
* game-logic.js — 【L3 游戏本体】木版年画作坊 · 工序节拍 + 暴击 combo + 吉祥图样解锁
*
* 玩法核心(60s 赶工):
* · 一件年画 = 4 道工序链:刻版 → 上墨 → 铺纸 → 拓印
* · 工序依次就绪(craftMs 加工后转 ready),火候窗口(windowMs)内点掉=完成
* · 过窗回炉(REDO_MS 冷却后该道重做,损时间不清进度)
* · 整条链走完 = 印出一张年画(bonus 加分 + 庆祝)
* · **正窗暴击**:窗口前 40% 出手 → 工钱 ×CRIT_MULT,连续正窗叠 combo
* · **吉祥图样解锁**:累计工钱达标(80/160)→ 麒麟/童子抱鱼入图鉴可印
* · 60s 到点切 over 结算,再开一印
*
* 已在 _forensicsView.state 实时暴露工资 + combo + 暴击 + 解锁图样列表,
* 满足九门 H 进展门(score 单调上升)与 E_live(targets 含就绪工位)。
*
* 入参(L1 已摊平):createGame({ plugins, bundle, viewport })
* 受控面 ctx 在 init(boot) 的 boot.ctx。
* 返回:init/update/render/destroy + handleTap + _forensicsView。
*/
'use strict';
import {
ROUND_MS, CRAFT_CHAIN, FIRST_READY_DELAY_MS, REDO_MS,
WORKS, UNLOCK_PROGRESS_TOTAL, CRIT_MULT,
computeStations, makeWork, shouldBecomeReady, isWindowExpired,
isStepTappable, hitStation, stepPoints, isCritWindow,
} 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 FLOAT_LIFE_S = 0.95;
const FLOAT_RISE_PXPS = 72;
/** combo 高亮飘字延长生命。 */
const COMBO_FLOAT_LIFE_S = 1.3;
/** 呼吸周期 220ms(就绪工位的脉冲)——用内部累计毫秒算 0..1,避免依赖裸 performance.now。 */
const PULSE_PERIOD_MS = 220;
export function createGame({ plugins, bundle, viewport }) {
// 用 6 件:场景机 / 计分 / UI / 计时 / 音效 / 粒子(节拍 + 出彩)。
const { sceneFsm, sessionScore, hudUi, timerScheduler, audioMusic, juice } = 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 stations = computeStations(VW, VH); // 工位中心(读 viewport,定一次)
let work = null; // 当前作品
let floats = []; // 飘字
let elapsedMs = 0; // play 内累计毫秒
let nextWorkId = 0; // 作品序号
let wage = 0; // 当日工钱(随完成工序上升)
let worksDone = []; // 本局印出的作品名(结算 + 取证)
let redos = 0; // 回炉次数
let roundTimer = null; // 60s 倒计时句柄
/* ── 双层奖励 / 正窗暴击 / combo ── */
let combo = 0; // 连续正窗次数
let bestCombo = 0; // 本局最高 combo
let totalCrits = 0; // 本局暴击总次数
let lastCrit = false; // 上一次出手是否暴击(取证可见)
/* ── 命中区(菜单/结算按钮):render 回调实时算并写回 ── */
let menuStartBtn = null;
let overRestartBtn = null;
/* ── 取证度量 ── */
const measures = { frames: 0, taps: 0, steps: 0, works: 0, redos: 0, crits: 0 };
/** 受控随机 [0,1):经 boot.ctx.random。 */
function rng() { return ctx ? ctx.random.next() : 0; }
/** 进 play:重置开工态、起 60s 倒计时。 */
function enterPlay() {
sessionScore.reset();
floats = [];
elapsedMs = 0;
wage = 0; worksDone = []; redos = 0;
combo = 0; bestCombo = 0; totalCrits = 0; lastCrit = false;
measures.steps = 0; measures.works = 0; measures.redos = 0; measures.crits = 0;
nextWorkId = 0;
work = makeWork(rng, 0, nextWorkId++, FIRST_READY_DELAY_MS, wage, WORKS);
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
roundTimer = timerScheduler.after(ROUND_MS, () => {
sessionScore.lose();
sceneFsm.transition(SCENE_OVER);
if (ctx) ctx.log('scene', 'over 收工', { score: sessionScore.getScore(), wage, works: worksDone.length, redos, bestCombo });
});
sceneFsm.transition(SCENE_PLAY);
if (ctx) ctx.log('scene', 'play 开工', { roundMs: ROUND_MS, chain: CRAFT_CHAIN.map((s) => s.name).join('→') });
}
/** L3 输入入口: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) {
// 工序核心:点中就绪工位 = 完成该道(1 步成序,自动验收据此涨分)。
if (!work || !isStepTappable(work, elapsedMs)) return;
const st = stations[work.stepIndex];
if (st && hitStation(x, y, st)) completeStep(st);
}
}
/** 完成当前工序:基础分 + 暴击判定 + combo + 飘字 + 粒子 + 音效。 */
function completeStep(st) {
measures.steps += 1;
const base = stepPoints(work.stepIndex);
const crit = isCritWindow(work, elapsedMs);
lastCrit = crit;
let amount = base;
if (crit) {
amount = Math.round(base * CRIT_MULT);
totalCrits += 1; measures.crits += 1;
combo += 1; if (combo > bestCombo) bestCombo = combo;
} else {
combo = 0;
}
// combo 额外加成(2 连起每连 +10%,叠到原数上,封顶 1.5x)
if (combo >= 2) {
const bonus = Math.min(0.5, (combo - 1) * 0.1);
amount = Math.round(amount * (1 + bonus));
}
wage += amount;
sessionScore.add(amount); // 计分即工钱(score 单调上升 → H 进展门)
// 飘字
const life = crit ? COMBO_FLOAT_LIFE_S : FLOAT_LIFE_S;
if (crit) {
floats.push({ x: st.x, y: st.y - 70, age: 0, life, amount, crit: true });
if (combo >= 2) {
floats.push({ x: st.x, y: st.y - 110, age: 0, life: COMBO_FLOAT_LIFE_S, amount: combo, combo: true });
}
} else {
floats.push({ x: st.x, y: st.y - 70, age: 0, life, amount });
}
if (juice) { try { juice.burst(st.x, st.y - 30, crit ? { count: 18 } : undefined); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx(crit ? 'chime' : 'click'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('step', '工序完成', { step: CRAFT_CHAIN[work.stepIndex].name, base, crit, combo, amount, total: wage });
if (work.stepIndex + 1 >= CRAFT_CHAIN.length) {
finishWork();
} else {
work.stepIndex += 1;
work.stepState = 'crafting';
work.stateSinceMs = elapsedMs;
const fresh = makeWork(rng, elapsedMs, work.id, undefined, wage, WORKS);
work.craftMs = fresh.craftMs;
work.windowMs = fresh.windowMs;
work.name = fresh.name; work.glyph = fresh.glyph; work.bgColor = fresh.bgColor; work.bonus = fresh.bonus;
}
}
/** 出成品:成品奖励 + 庆祝 + 记入当日作品,开下一件。 */
function finishWork() {
measures.works += 1;
wage += work.bonus;
sessionScore.add(work.bonus);
worksDone.push(work.name);
floats.push({ x: VW / 2, y: VH * 0.36, age: 0, life: FLOAT_LIFE_S * 1.5, amount: work.bonus, big: work.name });
if (juice) { try { juice.burst(VW / 2, VH * 0.4, { count: 26 }); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx('win'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('work', '印出年画', { name: work.name, glyph: work.glyph, bonus: work.bonus, works: worksDone.length });
work = makeWork(rng, elapsedMs, nextWorkId++, undefined, wage, WORKS);
}
/** 过窗回炉:该道重做 + 轻屏震 + 闷响 + combo 断。 */
function redoStep() {
redos += 1; measures.redos += 1;
work.stepState = 'crafting';
work.stateSinceMs = elapsedMs;
work.craftMs = REDO_MS;
combo = 0; // 回炉断 combo
if (juice) { try { juice.shakeScreen(0.18, 4); } catch (_) { /* 容错 */ } }
if (audioMusic) { try { audioMusic.playSfx('thud'); } catch (_) { /* 容错 */ } }
if (ctx) ctx.log('redo', '火候过了回炉', { step: CRAFT_CHAIN[work.stepIndex].name, redos });
}
/** 工序状态推进:加工满时长 → 就绪;就绪过窗 → 回炉。 */
function updateWork() {
if (!work) return;
if (shouldBecomeReady(work, elapsedMs)) {
work.stepState = 'ready';
work.stateSinceMs = elapsedMs;
if (audioMusic) { try { audioMusic.playSfx('blip'); } catch (_) { /* 容错 */ } }
} else if (isWindowExpired(work, elapsedMs)) {
redoStep();
}
}
/** 飘字上飘 + 淡出。 */
function updateFloats(dt) {
for (let i = floats.length - 1; i >= 0; i--) {
const f = floats[i];
f.age += dt; f.y -= FLOAT_RISE_PXPS * dt;
if (f.age >= f.life) floats.splice(i, 1);
}
}
/** 呼吸相位 0..1(基于 elapsedMs 算 sin 输入,纯确定性)——给 renderPlay 让就绪工位脉冲。 */
function pulse01() {
return 0.5 + 0.5 * Math.sin((elapsedMs / PULSE_PERIOD_MS) * Math.PI * 2);
}
/** 三场景注册(每场景各调一次 define)。 */
function defineScenes() {
sceneFsm.define(SCENE_MENU, {
onEnter() { floats = []; menuStartBtn = null; },
render(g) { menuStartBtn = renderMenu(g, hudUi, { vw: VW, vh: VH }).startBtn; },
});
sceneFsm.define(SCENE_PLAY, {
render(g) {
renderPlay(g, hudUi, {
vw: VW, vh: VH, stations, work, floats,
wage, works: worksDone.length, nowMs: elapsedMs,
remainMs: Math.max(0, ROUND_MS - elapsedMs),
pulse01: pulse01(),
});
},
});
sceneFsm.define(SCENE_OVER, {
onEnter() { if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; } },
render(g) {
overRestartBtn = renderOver(g, hudUi, {
vw: VW, vh: VH, wage, worksDone, redos, best: sessionScore.getBest(),
}).restartBtn;
},
});
}
/* ════════════ GameInstance 五法 ════════════ */
const instance = {
async init(b) {
boot = b;
ctx = b.ctx;
assets = await loadAssets('./assets/');
stations = computeStations(VW, VH);
defineScenes();
sceneFsm.start(SCENE_MENU);
inited = true;
ctx.log('init', '年画作坊就绪 menu', { vw: VW, vh: VH, steps: CRAFT_CHAIN.length });
},
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) {
elapsedMs += dt * 1000;
updateWork();
updateFloats(dt);
}
},
render(g) {
if (!inited) return;
sceneFsm.render(g);
if (juice && typeof juice.render === 'function') {
try { juice.render(g); } catch (_) { /* 容错 */ }
}
},
handleTap,
destroy() {
if (roundTimer != null) { try { timerScheduler.cancel(roundTimer); } catch (_) { /* 幂等 */ } roundTimer = null; }
},
/**
* 取证契约:实时算 phase/score/targets/combo/解锁等。
* ★ 必须在函数体内实时算,不能闭包冻结(否则 host 读 boot 快照=永 menu)。
* targets = 每工位一项;当前工序在窗口内可点 → occupied:true,
* 其余 false(自动验收 driver 据此驱动游戏)。
*/
_forensicsView() {
return {
state() {
const cur = sceneFsm ? sceneFsm.current() : null;
// 实时算 targets(每工位)
const targets = [];
for (let i = 0; i < stations.length; i++) {
const st = stations[i];
let occupied = false;
if (cur === SCENE_PLAY && work && i === work.stepIndex && isStepTappable(work, elapsedMs)) {
occupied = true;
}
targets.push({ x: st.x, y: st.y, occupied });
}
return {
phase: cur,
score: sessionScore ? sessionScore.getScore() : wage,
targets,
wage,
works: worksDone.length,
redos,
combo,
bestCombo,
crits: totalCrits,
lastCrit,
nextStep: work ? (CRAFT_CHAIN[work.stepIndex] && CRAFT_CHAIN[work.stepIndex].name) : null,
unlocked: (WORKS || []).filter((w) => wage >= (w.unlockAtWage || 0)).map((w) => w.name),
remainMs: Math.max(0, ROUND_MS - elapsedMs),
};
},
measures() {
return Object.assign({}, measures, { score: sessionScore ? sessionScore.getScore() : wage, wage, works: worksDone.length, redos, combo, bestCombo, crits: totalCrits });
},
};
},
};
return instance;
}

View File

@ -0,0 +1,379 @@
/**
* render.js — 【L3 表现层】木版年画作坊 · 绘制
* 一律画在入参 g(= boot.mainContext)。本款全程序化绘制(无外部美术)。
*
* 美术风格:「纸艺高对比」—— 宣纸白底 + 朱红/靛蓝/藤黄(年画调性);
* 案台面 = 一张长宣纸;工位 = 各色墨印/工具;成品出炉展示朱红方框大字。
*/
'use strict';
import { CRAFT_CHAIN, WORKS, UNLOCK_PROGRESS_TOTAL } from './core.js';
/* ── 配色(年画调性:朱砂红 + 靛蓝 + 藤黄 + 宣纸米白)── */
const C = {
// 背景
bgWall: '#2b1810', // 深木墙
bgWallAccent: '#3d261a',
lattice: '#5c3a26',
// 案台(宣纸)
paper: '#f3e8c2', // 宣纸米白
paperEdge: '#d8c896',
paperShadow: 'rgba(0,0,0,0.35)',
// 文本
text: '#241208', // 在宣纸上的字
textLight: '#fbeec2', // 在深底上的字
textDim: '#8c6a4a',
// 状态
ready: '#1f7a3a', // 就绪:墨绿
readyGlow: '#7ed957',
warn: '#caa84a',
danger: '#c8261a',
done: '#7a9b3e', // 完成:藤黄绿
pending: 'rgba(60,40,20,0.45)',
// 朱砂红(年画主色)
vermilion: '#c8261a',
vermilionDark: '#7a1810',
// 靛蓝
indigo: '#1f4e7a',
// 装饰
accentGold: '#caa84a',
panel: 'rgba(0,0,0,0.55)',
panelLight: 'rgba(255,236,194,0.92)',
stroke: 'rgba(255,236,194,0.35)',
strokeDark: 'rgba(36,18,8,0.55)',
};
/* ═══════════════════════════════════════════════════════════════════════════ */
/** 铺底:深木墙 + 木格窗 + 牌匾(年画坊名)。 */
function drawBackdrop(g, VW, VH) {
// 深木墙
g.fillStyle = C.bgWall; g.fillRect(0, 0, VW, VH * 0.50);
g.fillStyle = C.bgWallAccent; g.fillRect(0, VH * 0.50, VW, VH * 0.50);
// 横梁
g.fillStyle = '#1a0e08'; g.fillRect(0, 0, VW, 8);
g.fillStyle = '#1a0e08'; g.fillRect(0, VH * 0.50 - 4, VW, 8);
// 两扇木格窗
const winY = 70, winH = 90, winW = 72;
for (let i = 0; i < 2; i++) {
const wx = VW * (0.22 + i * 0.56) - winW / 2;
g.fillStyle = '#160c06'; g.fillRect(wx, winY, winW, winH);
g.strokeStyle = C.lattice; g.lineWidth = 2.5; g.strokeRect(wx, winY, winW, winH);
g.beginPath();
g.moveTo(wx + winW / 2, winY); g.lineTo(wx + winW / 2, winY + winH);
g.moveTo(wx, winY + winH / 2); g.lineTo(wx + winW, winY + winH / 2);
g.stroke();
}
// 牌匾(年画坊)—— 标题叠在 renderMenu 里画
g.fillStyle = C.vermilion;
g.fillRect(VW / 2 - 110, 22, 220, 38);
g.strokeStyle = C.vermilionDark; g.lineWidth = 3;
g.strokeRect(VW / 2 - 110, 22, 220, 38);
}
/** 一张长宣纸案台。 */
function drawBench(g, VW, VH) {
// 案台阴影
g.fillStyle = C.paperShadow;
g.fillRect(VW * 0.04, VH * 0.565, VW * 0.92, VH * 0.18);
// 宣纸
g.fillStyle = C.paper;
g.fillRect(VW * 0.06, VH * 0.555, VW * 0.88, VH * 0.16);
// 纸边毛笔感(浅描边)
g.strokeStyle = C.paperEdge; g.lineWidth = 2;
g.strokeRect(VW * 0.06, VH * 0.555, VW * 0.88, VH * 0.16);
// 案台桌脚暗影
g.fillStyle = '#1a0e08';
g.fillRect(0, VH * 0.715, VW, 6);
}
/**
* 一个工位:工具/物件图 + 状态圈 + 工序名。
* 状态: 'pending' | 'crafting' | 'ready' | 'done'
* 呼吸脉冲用入参 pulse01(0..1)驱动,避免依赖裸 performance.now。
*/
function drawStation(g, st, step, state, ratio, pulse01) {
// 工位阴影
g.beginPath(); g.ellipse(st.x, st.y + 38, 42, 10, 0, 0, Math.PI * 2);
g.fillStyle = C.paperShadow; g.fill();
// 工位物件(按工序绘制不同工具)
drawTool(g, st.x, st.y, step, state);
// 状态圈
if (state === 'ready') {
// 就绪:高亮绿圈 + 呼吸
const pulse = 0.85 + 0.15 * (pulse01 != null ? pulse01 : 0.5);
g.beginPath(); g.arc(st.x, st.y, 40 * pulse, 0, Math.PI * 2);
g.strokeStyle = C.readyGlow; g.lineWidth = 4; g.stroke();
g.beginPath(); g.arc(st.x, st.y, 46, 0, Math.PI * 2);
g.strokeStyle = C.ready; g.lineWidth = 2; g.stroke();
// 火候窗口条
const barW = 70, barH = 8, bx = st.x - barW / 2, by = st.y - 62;
g.fillStyle = 'rgba(36,18,8,0.55)'; g.fillRect(bx, by, barW, barH);
g.fillStyle = ratio > 0.5 ? C.readyGlow : (ratio > 0.25 ? C.warn : C.danger);
g.fillRect(bx, by, barW * ratio, barH);
g.strokeStyle = C.strokeDark; g.lineWidth = 1; g.strokeRect(bx, by, barW, barH);
} else if (state === 'crafting') {
// 加工中:虚线黄圈
g.setLineDash([5, 5]); g.beginPath(); g.arc(st.x, st.y, 38, 0, Math.PI * 2);
g.strokeStyle = C.warn; g.lineWidth = 3; g.stroke(); g.setLineDash([]);
} else if (state === 'done') {
g.beginPath(); g.arc(st.x, st.y, 36, 0, Math.PI * 2);
g.strokeStyle = C.done; g.lineWidth = 3; g.stroke();
// 打勾
g.beginPath();
g.moveTo(st.x - 12, st.y);
g.lineTo(st.x - 4, st.y + 9);
g.lineTo(st.x + 14, st.y - 10);
g.strokeStyle = C.done; g.lineWidth = 4; g.stroke();
} else {
// pending:虚线灰圈
g.setLineDash([3, 5]); g.beginPath(); g.arc(st.x, st.y, 36, 0, Math.PI * 2);
g.strokeStyle = C.pending; g.lineWidth = 2; g.stroke(); g.setLineDash([]);
}
// 工序名
g.font = '700 14px "PingFang SC", "Microsoft YaHei", sans-serif';
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = state === 'pending' ? C.textDim : C.text;
g.fillText(step.name, st.x, st.y + 56);
}
/** 工位上的工具/物件(每道工序不同,程序化绘制)。 */
function drawTool(g, x, y, step, state) {
const col = state === 'pending' ? C.pending : step.color;
g.save();
g.translate(x, y);
const idx = step.name;
if (idx === '刻版') {
// 木版 + 刻线
g.fillStyle = '#7a4a26';
g.fillRect(-22, -10, 44, 22);
g.fillStyle = col;
g.fillRect(-22, -10, 44, 4);
g.strokeStyle = col; g.lineWidth = 2;
g.beginPath(); g.moveTo(-14, -2); g.lineTo(-6, 6); g.stroke();
g.beginPath(); g.moveTo(0, -4); g.lineTo(8, 4); g.stroke();
g.beginPath(); g.moveTo(-10, 6); g.lineTo(10, -2); g.stroke();
// 刻刀(右上)
g.fillStyle = '#444';
g.fillRect(14, -22, 4, 14);
g.fillStyle = '#888';
g.beginPath();
g.moveTo(16, -8); g.lineTo(13, -2); g.lineTo(19, -2); g.closePath();
g.fill();
} else if (idx === '上墨') {
// 墨辊
g.save();
g.rotate(-0.3);
g.fillStyle = '#5a3a24';
g.fillRect(-24, -4, 48, 8);
g.fillStyle = col;
g.fillRect(-24, -4, 48, 4);
g.fillStyle = '#7a4a26';
g.fillRect(-30, -3, 6, 6);
g.fillRect(24, -3, 6, 6);
g.restore();
} else if (idx === '铺纸') {
// 宣纸方块
g.fillStyle = col;
g.fillRect(-20, -16, 40, 32);
g.strokeStyle = C.strokeDark; g.lineWidth = 1.5;
g.strokeRect(-20, -16, 40, 32);
g.beginPath();
g.moveTo(-20, 0); g.lineTo(20, 0);
g.strokeStyle = C.paperEdge; g.lineWidth = 1; g.stroke();
} else if (idx === '拓印') {
// 棕刷 + 版
g.fillStyle = '#7a4a26';
g.fillRect(-22, -4, 44, 16);
g.fillStyle = col;
g.fillRect(-22, -4, 44, 4);
g.strokeStyle = col; g.lineWidth = 1.5;
for (let i = 0; i < 5; i++) {
const px = -18 + i * 9;
g.beginPath(); g.moveTo(px, 12); g.lineTo(px + 2, 18); g.stroke();
}
} else {
g.beginPath(); g.arc(0, 0, 22, 0, Math.PI * 2);
g.fillStyle = col; g.fill();
}
g.restore();
}
/** 顶部 HUD:工钱 + 成品数 + 倒计时。 */
function drawHud(g, hud, info) {
hud.drawPanel(g, { x: 8, y: 76, w: info.vw - 16, h: 52 }, { fill: C.panel, stroke: C.stroke, lineWidth: 2, radius: 12 });
hud.drawText(g, '工钱 ' + info.wage, 22, 102, { font: '700 22px "PingFang SC",sans-serif', color: C.accentGold, align: 'left', baseline: 'middle' });
hud.drawText(g, '年画 ' + info.works, info.vw / 2, 102, { font: '600 18px "PingFang SC",sans-serif', color: C.textLight, align: 'center', baseline: 'middle' });
const sec = Math.max(0, Math.ceil(info.remainMs / 1000));
const tcol = info.remainMs < 10000 ? C.danger : C.textLight;
hud.drawText(g, '剩余 ' + sec + 's', info.vw - 22, 102, { font: '700 20px "PingFang SC",sans-serif', color: tcol, align: 'right', baseline: 'middle' });
}
/** 当前作品条 + 图样大字符(让玩家看到在印什么)。 */
function drawWorkLine(g, hud, view) {
if (!view.work) return;
const idx = view.work.stepIndex;
const total = CRAFT_CHAIN.length;
const stepName = CRAFT_CHAIN[idx] ? CRAFT_CHAIN[idx].name : '';
hud.drawPanel(g, { x: view.vw * 0.10, y: view.vh * 0.42, w: view.vw * 0.80, h: 36 },
{ fill: C.panelLight, stroke: C.strokeDark, lineWidth: 1.5, radius: 8 });
hud.drawText(g, '在印:' + view.work.name + ' · ' + stepName + '(' + (idx + 1) + '/' + total + ')',
view.vw / 2, view.vh * 0.42 + 18, { font: '700 16px "PingFang SC",sans-serif', color: C.text, align: 'center', baseline: 'middle' });
}
/** 图鉴条(底部):已解锁/未解锁的图样预览 —— 「印得多解锁新吉祥图样」的视觉钩子。 */
function drawCodex(g, hud, view) {
const yTop = view.vh * 0.78;
// 浅纸面
hud.drawPanel(g, { x: view.vw * 0.06, y: yTop, w: view.vw * 0.88, h: view.vh * 0.16 },
{ fill: C.paper, stroke: C.strokeDark, lineWidth: 1.5, radius: 10 });
// "图鉴"小标
hud.drawText(g, '图鉴', view.vw * 0.10, yTop + 14, { font: '700 12px "PingFang SC",sans-serif', color: C.text, align: 'left', baseline: 'middle' });
// 解锁进度条
const pbW = view.vw * 0.55;
hud.drawBar(g, { x: view.vw * 0.18, y: yTop + 8, w: pbW, h: 8 }, Math.min(1, view.wage / UNLOCK_PROGRESS_TOTAL),
{ bg: 'rgba(0,0,0,0.25)', fill: C.vermilion, stroke: C.strokeDark, radius: 4 });
hud.drawText(g, view.wage + ' / ' + UNLOCK_PROGRESS_TOTAL, view.vw * 0.18 + pbW + 6, yTop + 12, { font: '600 11px sans-serif', color: C.textDim, align: 'left', baseline: 'middle' });
// 图样格(每件一格:大字 + 解锁/未解锁底色)
const cellW = 44, cellH = 56;
const cellY = yTop + 28;
const startX = view.vw * 0.18;
for (let i = 0; i < WORKS.length; i++) {
const w = WORKS[i];
const cx = startX + i * (cellW + 4);
const unlocked = view.wage >= (w.unlockAtWage || 0);
// 格背景
hud.drawPanel(g, { x: cx, y: cellY, w: cellW, h: cellH },
{ fill: unlocked ? w.bgColor : 'rgba(60,40,20,0.18)', stroke: unlocked ? w.bgColor : C.textDim, lineWidth: 1.5, radius: 6 });
if (unlocked) {
// 大字
g.fillStyle = '#fbeec2';
g.font = '900 26px "STKaiti", "KaiTi", "PingFang SC", serif';
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText(w.glyph, cx + cellW / 2, cellY + 24);
hud.drawText(g, w.name, cx + cellW / 2, cellY + cellH - 6, { font: '600 10px "PingFang SC",sans-serif', color: '#fbeec2', align: 'center', baseline: 'middle' });
} else {
// 锁图标(用文字符号)
g.fillStyle = C.textDim;
g.font = '700 18px sans-serif';
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText('LOCK', cx + cellW / 2, cellY + 22);
hud.drawText(g, w.unlockAtWage + '钱', cx + cellW / 2, cellY + cellH - 6, { font: '600 10px "PingFang SC",sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
}
}
}
/** 完成飘字。 */
function drawFloats(g, floats) {
for (const f of floats) {
const alpha = Math.max(0, 1 - f.age / f.life);
if (alpha <= 0) continue;
g.save(); g.globalAlpha = alpha;
g.textAlign = 'center'; g.textBaseline = 'middle';
if (f.combo) {
g.font = '900 26px "PingFang SC",sans-serif';
g.fillStyle = C.accentGold; g.strokeStyle = '#241208'; g.lineWidth = 4;
g.strokeText('连击 ×' + f.amount, f.x, f.y);
g.fillText('连击 ×' + f.amount, f.x, f.y);
} else if (f.crit) {
g.font = '900 28px "PingFang SC",sans-serif';
g.fillStyle = C.vermilion; g.strokeStyle = '#241208'; g.lineWidth = 4;
g.strokeText('正窗! +' + f.amount, f.x, f.y);
g.fillText('正窗! +' + f.amount, f.x, f.y);
} else if (f.big) {
g.font = '900 30px "STKaiti","KaiTi",serif';
g.fillStyle = C.danger; g.strokeStyle = '#241208'; g.lineWidth = 5;
g.strokeText(f.big + ' 印成! +' + f.amount, f.x, f.y);
g.fillText(f.big + ' 印成! +' + f.amount, f.x, f.y);
} else {
g.font = '800 24px "PingFang SC",sans-serif';
g.fillStyle = C.accentGold; g.strokeStyle = '#241208'; g.lineWidth = 4;
g.strokeText('+' + f.amount, f.x, f.y);
g.fillText('+' + f.amount, f.x, f.y);
}
g.restore();
}
}
/* ────────── 场景绘制入口 ────────── */
/** menu:牌匾标题 + 开工按钮。 */
export function renderMenu(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
// 暗化
g.fillStyle = 'rgba(15,8,4,0.55)'; g.fillRect(0, 0, view.vw, view.vh);
// 牌匾标题(画在牌匾上)
hud.drawText(g, '年画作坊', view.vw / 2, 41, { font: '900 24px "STKaiti","KaiTi",serif', color: C.textLight, align: 'center', baseline: 'middle' });
// 大标题
hud.drawText(g, '赶在年前', view.vw / 2, view.vh * 0.26, { font: '900 42px "STKaiti","KaiTi",serif', color: C.vermilion, align: 'center', baseline: 'middle' });
hud.drawText(g, '印出最多年画', view.vw / 2, view.vh * 0.26 + 56, { font: '900 32px "STKaiti","KaiTi",serif', color: C.accentGold, align: 'center', baseline: 'middle' });
// 副标
hud.drawText(g, '60s 内刻版→上墨→铺纸→拓印,工钱越多解锁新图样',
view.vw / 2, view.vh * 0.45, { font: '14px "PingFang SC",sans-serif', color: C.textLight, align: 'center', baseline: 'middle' });
// 开工按钮
const w = 240, h = 60;
const startBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.62 - h / 2, w, h },
{ label: '开 印', font: '800 24px "STKaiti","KaiTi",serif', fill: C.vermilion, textColor: C.textLight, radius: 30 });
return { startBtn };
}
/**
* play:背景 + 工位链 + 作品条 + 图鉴 + 飘字 + HUD。
* pulse01 = 0..1 的呼吸相位(0..1 循环),由 game-logic 经受控时钟算后传入。
*/
export function renderPlay(g, hud, view) {
const { vw, vh, stations, work, floats, wage, works, remainMs, nowMs, pulse01 } = view;
drawBackdrop(g, vw, vh);
drawBench(g, vw, vh);
drawWorkLine(g, hud, view);
// 工位
for (const st of stations) {
const step = CRAFT_CHAIN[st.station] || CRAFT_CHAIN[0];
let state = 'pending';
let ratio = 0;
if (work) {
if (st.station < work.stepIndex) state = 'done';
else if (st.station === work.stepIndex) {
if (work.stepState === 'ready') {
const waited = nowMs - work.stateSinceMs;
ratio = Math.max(0, Math.min(1, (work.windowMs - waited) / work.windowMs));
state = 'ready';
} else {
state = 'crafting';
}
}
}
drawStation(g, st, step, state, ratio, pulse01);
}
drawFloats(g, floats);
drawHud(g, hud, { vw, vh, wage, works, remainMs });
drawCodex(g, hud, { vw, vh, wage });
}
/** over:今日工钱 + 出炉作品展示 + 图鉴进度 + 最佳纪录 + 再开按钮。 */
export function renderOver(g, hud, view) {
drawBackdrop(g, view.vw, view.vh);
g.fillStyle = 'rgba(15,8,4,0.60)'; g.fillRect(0, 0, view.vw, view.vh);
// 标题
hud.drawText(g, '收 工 咯', view.vw / 2, view.vh * 0.16, { font: '900 36px "STKaiti","KaiTi",serif', color: C.vermilion, align: 'center', baseline: 'middle' });
hud.drawText(g, '工钱 ' + view.wage, view.vw / 2, view.vh * 0.26, { font: '900 50px "STKaiti","KaiTi",serif', color: C.accentGold, align: 'center', baseline: 'middle' });
// 成品展示条
const itemY = view.vh * 0.36;
hud.drawPanel(g, { x: view.vw * 0.08, y: itemY, w: view.vw * 0.84, h: 64 },
{ fill: C.paper, stroke: C.strokeDark, lineWidth: 2, radius: 10 });
const names = (view.worksDone && view.worksDone.length) ? view.worksDone.join(' · ') : '今日没印出年画';
hud.drawText(g, '印出:' + names, view.vw / 2, itemY + 22, { font: '700 16px "PingFang SC",sans-serif', color: C.text, align: 'center', baseline: 'middle' });
hud.drawText(g, '成品 ' + (view.worksDone ? view.worksDone.length : 0) + ' 回炉 ' + view.redos,
view.vw / 2, itemY + 46, { font: '600 15px "PingFang SC",sans-serif', color: C.textDim, align: 'center', baseline: 'middle' });
// 最佳纪录
hud.drawText(g, '最佳工钱 ' + view.best, view.vw / 2, view.vh * 0.50, { font: '700 18px "PingFang SC",sans-serif', color: C.accentGold, align: 'center', baseline: 'middle' });
// 提示
hud.drawText(g, '印得越多,「麒麟」「童子抱鱼」会陆续解锁哦~',
view.vw / 2, view.vh * 0.56, { font: '13px "PingFang SC",sans-serif', color: C.textLight, align: 'center', baseline: 'middle' });
// 再开按钮
const w = 240, h = 60;
const restartBtn = hud.drawButton(g, { x: view.vw / 2 - w / 2, y: view.vh * 0.70 - h / 2, w, h },
{ label: '再开一印', font: '800 24px "STKaiti","KaiTi",serif', fill: C.vermilion, textColor: C.textLight, radius: 30 });
return { restartBtn };
}

View File

@ -17,6 +17,8 @@
* · 换手艺/工序/作品/难度 → 改 core.js 顶部的多样性参数空间(CRAFT_CHAIN、WORKS、节奏常量)
* · 画面观感(案台长啥样、工位长啥样、配色)→ 改 render.js
* · 想加机制(正窗暴击 combo、解锁新作品、图鉴)→ 在循环里加,但**别破坏 targets/score 契约**
* · ⚠ 改写本文件时 **import 列表必须与你用到的 core.js 导出一一对齐**:漏 import 的裸标识符
* check/build 都拦不住(esbuild 当全局放行),运行时点一下就 ReferenceError 被隔离 → 永卡菜单、九门全挂。
*
* 入参(L1 已摊平):createGame({ plugins, bundle, viewport });受控面 ctx 在 init(boot) 的 boot.ctx。
* 返回 GameInstance:init/update/render/destroy + handleTap + _forensicsView。