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