feat(polaris): 《山海巡异录》表现层精致化——UI 去方框(青绿卷轴)+国风器物音效
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

创始人验玩批 UI 方框/音效芯片音。关键发现:UI 帧(ui-frame/bg-scene)此前生成却零接线、run-scene 全 graphics
方框=方方正正根因。新建 skin.js 青绿卷轴皮肤基元单源(真素材优先+程序兜底+node 安全),run-scene 全屏改绘:
满幅山水背景/稀有度册页卡框+描金费珠/卷轴 HUD/灵力宝珠/五行链轴珠+相生脉动/敌区名号匾额+意图徽/三漆色钉角按钮/
铜章地图节点/册页奖励商店图鉴成就;动效=出牌飞行灵符/周天金环放射×3+满屏金闪/奖励回弹/终局弹入,既有 juice 保留。
生成 4 件 UI chrome(锚 r1 抠透明落 assets/ui/ 376KB:宝珠/铜章/匾额/横条)缺件退程序绘制。内容音效:
sfx.js 单振荡器升国风分层合成(磬/钟/铃/古琴拨弦/鼓膜/木鱼六音族对齐山海器物),链环八级五声音阶保留换三泛音磬音。
证据:八门+手感门 42 断言全绿+数值 CI 金标 52.0%(漂移-0.5pp);scene-smoke 绘制对象 map=54/battle=42(原~20)
=去方框硬证;CDP 探针音频节点 0→osc2/noise1/filter1。核心数值文件未动。音效路线=山海合成(符创始人拍)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 17:15:39 -07:00
parent b4a6956bdb
commit c931ea4181
9 changed files with 878 additions and 295 deletions

View File

@ -10,10 +10,20 @@ M3 内容完备之上做工艺打磨,全部**只消费事件流、不改 battle-
落纯函数(`src/ui/presentation.js`),门直接验,像素观感/音频时延/音质真机 mini-desktop 窗口验(红线④)。
- **演出 v2**(S17):真卡插画/敌兽立绘接图集(`src/ui/atlas.js`,帧名=id,13 atlas,未就绪降级程序绘制);
伤害数字+受击颗粒、链轴金闪(逐环点亮)、周天闪光、总量爆字(48px)、屏震(≤4px/90ms)、结算全长 ≤1.6s
(`computeSettlementPlan` 超批压缩)。敌身呼吸缩放。
- **音频全量**(S18):SFX 程序合成(`src/audio/sfx.js`,WebAudio 无采样,零包体,24 音效,链环八级五声音阶
递升);BGM 局面管理(`src/audio/bgm.js`,菜单/战斗/Boss/结算 4 曲映射+淡切+手势解锁)。
伤害数字+受击颗粒、链轴金闪(逐环点亮)、周天闪光(金环放射×3+满屏金闪+爆字)、总量爆字(48px)、
屏震(≤4px/90ms)、结算全长 ≤1.6s(`computeSettlementPlan` 超批压缩)、出牌飞行灵符。敌身呼吸缩放+承脚台影。
- **表现层精致化**(M4 打磨,创始人验玩批「UI 方方正正无设计」):青绿卷轴国风皮肤基元收敛
`src/ui/skin.js`(卷轴册页面板/描金勾线/灵力宝珠/五行珠/描金血槽/题头匾额/节点铜章/稀有度卡框),
run-scene 全屏改由它绘,去 canvas 方框。真素材优先——满幅山水背景(bg-scene/bg-result)、册页面板
(ui-bg-panel/hud)、稀有度卡框(ui-frame-*)全接线(此前生成即未用);缺件用 mmx 补生成 UI chrome
松散透明件(灵力宝珠/节点铜章/题头匾额/卷轴横条,落 `assets/ui/`,`preloadUiChrome` 装载,锚 r1、
台账留痕);headless/mock/首屏未就绪时同名基元退回程序绘制。绘制锚点严格对齐 layout.js 命中靶(美化不
平移可点区域)。像素观感真机 mini-desktop 验(红线④)。
- **音频全量**(S18 → M4 换厚音色):SFX 国风分层合成(`src/audio/sfx.js`,零包体、零外采/纯合成 clean-room)——
M0 的单振荡器「芯片音」升级为「多泛音+噪声瞬态+滤波+ADSR」分层合成,按内容分族对齐山海器物音色
(磬/钟/鼓/木鱼/古琴/铃/水声,见 `SOUND_DESIGN`):出牌按五行、受击按三强度、链环走八级磬音、周天走
锣鸣爆发、得金/遗物走铃磬;链环八级五声音阶保留(换三泛音磬音)。24 音效、node no-op 契约不变。
BGM 局面管理(`src/audio/bgm.js`,菜单/战斗/Boss/结算 4 曲映射+淡切+手势解锁)。
⚠️**BGM 音频文件受阻:mmx music 服务持续「生成准备失败」(4 次真尝试全败,免费+付费模型),文件待服务恢复;
系统与清单已完备、缺文件时优雅降级(bgm.js no-op 不崩)。**
- **juice 全量**(S19):手牌选中浮起放大 1.55×+抬升、上滑拖拽打出(过屏高 62% 线;点选两段式保留、语义走

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -1,16 +1,18 @@
/**
* src/audio/sfx.js —— 《山海巡异录》SFX 程序合成(M4 S18;WebAudio 无采样合成,零包体)
* owner:北极星顶级线③(M4)
* src/audio/sfx.js —— 《山海巡异录》SFX 国风分层合成(M4 打磨:去单振荡器"芯片音",换厚音色)
* owner:北极星顶级线③(M4 表现层精致化)
*
* 【为什么程序合成】书 §10:音效手感类(尤其链环点亮八级音阶)优先程序合成——零包体、参数化调音、
* 与结算演出逐环节拍同源(同一事件流驱动音与画,音画同帧)。有采样的 BGM 走 bgm.js。
* 【为什么分层合成而非采样】书 §10 音效手感优先程序合成——零包体、参数化随事件流同帧调音。M4 打磨把
* M0 的「单振荡器 ZzFX 芯片音」升级为分层合成:每个音由「多泛音叠加 + 噪声瞬态 + 滤波 + 独立 ADSR
* 包络」构成,音色对齐山海国风器物(磬/钟/鼓/木鱼/古琴/铃/水声),按内容分族——出牌按五行、受击按
* 三强度、链环走八级磬音、周天走锣鸣爆发、得金/遗物走铃磬。零外采(纯合成即最纯粹的 CC0/clean-room,
* 无授权面);音色设计留痕见 SOUND_DESIGN。音质本身真机 mini-desktop 听(红线④,不机验)。
*
* 【node import-safe】顶层零 AudioContext(node/headless 无);createSfx 在无 AudioContext 时整体降为
* no-op,但音阶表(CHAIN_SCALE)与音效谱(SFX_SPEC)恒可读——门只验结构(八级单调递增/清单齐全),
* 音质本身真机听(不机验,红线④「模型/听感判断不写成代码校验」)。
* 【node import-safe】顶层零 AudioContext(node/headless 无);createSfx 无 AudioContext 时整体 no-op,
* 但音阶表(CHAIN_SCALE)与音效谱(SFX_SPEC)恒可读——门只验结构(八级单调递增/清单齐全),不验音质。
*
* 【音阶】国风五声音阶(宫商角徵羽)纯律比率,跨两八度取前八音——链环点亮 chain0..7 音高递升,
* 听觉爽感轴与序列链 §6.3 每环视觉点亮同源。
* 【音阶】国风五声音阶(宫商角徵羽)纯律比率跨两八度取前八音——链环点亮 chain0..7 音高递升,听觉爽感轴
* 与序列链 §6.3 每环视觉点亮同源;M4 把每级从单三角波换成三泛音磬音(厚而清,金石之声)。
*
* 【手势解锁】浏览器策略:AudioContext 须用户手势后 resume()——宿主/场景在首个 pointerdown 调 resume。
*/
@ -31,39 +33,58 @@ export function chainFreq(n) {
/**
* SFX 音效谱(name → 合成参数;纯数据,门对清单齐全性/事件覆盖对账)。
* type:oscillator 波形 | 'noise' 白噪 | 'chord' 多音叠置;freq 基频 Hz;dur 秒;gain 峰值;
* glideTo:频率滑到目标(上/下行手感);noise:混入噪声比(受击颗粒感)。
* model:合成模型 —— bell(磬/钟:三泛音金石) | pluck(古琴/筝:谐波拨弦) | drum(鼓:掉频膜鸣+噪) |
* wood(木鱼:短促击木) | noise(水声/洗牌:带通噪声扫) | chord(磬和弦:琶音多音);
* freq 基频 Hz;dur 秒;gain 峰值;glideTo 频率滑动;noise 噪声瞬态比;partials 磬泛音数(bell);
* detune 微失谐(pluck 加厚);bright 亮度(低通截止倍率)。
* 与 presentation.sfxForEvent 的名一一对应(名不变,只换音色底);M0 的 type 单波形字段已由 model 取代。
*/
export const SFX_SPEC = {
play_mu: { type: 'triangle', freq: 330, dur: 0.12, gain: 0.16 }, // 木:柔和三角
play_huo: { type: 'sawtooth', freq: 440, dur: 0.11, gain: 0.15 }, // 火:锐利锯齿
play_tu: { type: 'square', freq: 196, dur: 0.14, gain: 0.14 }, // 土:厚重方波
play_jin: { type: 'square', freq: 587, dur: 0.10, gain: 0.13 }, // 金:高亢金属方波
play_shui: { type: 'sine', freq: 262, dur: 0.13, gain: 0.16 }, // 水:圆润正弦
hit_light: { type: 'sawtooth', freq: 220, dur: 0.08, gain: 0.14, noise: 0.3 },
hit_mid: { type: 'sawtooth', freq: 165, dur: 0.11, gain: 0.20, noise: 0.5 },
hit_heavy: { type: 'sawtooth', freq: 110, dur: 0.16, gain: 0.26, noise: 0.8 },
hurt: { type: 'square', freq: 98, dur: 0.18, gain: 0.22, noise: 0.6 }, // 玩家中招:低沉+颗粒
energy: { type: 'sine', freq: 660, dur: 0.10, gain: 0.14, glideTo: 990 }, // 得能量:清脆上行
draw: { type: 'triangle', freq: 520, dur: 0.05, gain: 0.10 }, // 抽牌:短促
discard: { type: 'triangle', freq: 300, dur: 0.06, gain: 0.09, glideTo: 210 },
shuffle: { type: 'noise', dur: 0.28, gain: 0.12 }, // 洗牌:噪声扫
gold: { type: 'sine', freq: 784, dur: 0.09, gain: 0.13, glideTo: 1046 }, // 得金:铃亮
buy: { type: 'triangle', freq: 587, dur: 0.12, gain: 0.14 },
remove: { type: 'sawtooth', freq: 300, dur: 0.16, gain: 0.14, glideTo: 140 }, // 删卡:下沉
reward: { type: 'sine', freq: 523, dur: 0.14, gain: 0.15, glideTo: 784 }, // 翻牌/得卡:上扬
relic: { type: 'triangle', freq: 415, dur: 0.30, gain: 0.16, glideTo: 622 }, // 得遗物:神秘上行
status: { type: 'square', freq: 370, dur: 0.09, gain: 0.10 }, // 施加词缀(易伤/虚弱/灼)
tap: { type: 'sine', freq: 440, dur: 0.04, gain: 0.08 }, // UI 点按
select: { type: 'sine', freq: 523, dur: 0.05, gain: 0.10 }, // 选中卡
zhoutian: { type: 'chord', freqs: [392, 494, 587, 784], dur: 0.6, gain: 0.20 }, // 周天:四音爆发
win: { type: 'chord', freqs: [523, 659, 784, 1046], dur: 0.9, gain: 0.20 }, // 胜利大和弦
lose: { type: 'chord', freqs: [196, 233, 262], dur: 1.0, gain: 0.18 }, // 失败低沉
// 出牌:按五行器物音色分族(木=柔筝、火=亮拨带噪、土=厚木鼓、金=金石磬、水=润拨)
play_mu: { model: 'pluck', freq: 294, dur: 0.24, gain: 0.16, detune: 6, bright: 1.6 }, // 木:柔和古琴拨
play_huo: { model: 'pluck', freq: 466, dur: 0.18, gain: 0.15, detune: 10, bright: 2.4, noise: 0.18 }, // 火:亮拨带焰噪
play_tu: { model: 'drum', freq: 174, dur: 0.2, gain: 0.18, glideTo: 120, noise: 0.25, bright: 1.1 }, // 土:厚土木鼓
play_jin: { model: 'bell', freq: 622, dur: 0.34, gain: 0.13, partials: 3, bright: 3 }, // 金:金石磬
play_shui: { model: 'pluck', freq: 349, dur: 0.26, gain: 0.15, detune: 4, bright: 1.4 }, // 水:润下拨
// 受击三强度:掉频膜鼓 + 噪声瞬态(越重越低沉、噪越多、掉频越深)
hit_light: { model: 'drum', freq: 260, dur: 0.1, gain: 0.16, glideTo: 150, noise: 0.35, bright: 1.6 },
hit_mid: { model: 'drum', freq: 180, dur: 0.14, gain: 0.22, glideTo: 90, noise: 0.55, bright: 1.3 },
hit_heavy: { model: 'drum', freq: 110, dur: 0.2, gain: 0.28, glideTo: 55, noise: 0.85, bright: 1.0 },
hurt: { model: 'drum', freq: 96, dur: 0.22, gain: 0.24, glideTo: 60, noise: 0.7, bright: 0.9 }, // 玩家中招:更闷
// 资源/流程音
energy: { model: 'bell', freq: 660, dur: 0.18, gain: 0.13, partials: 2, glideTo: 990, bright: 3 }, // 得能量:清脆上行磬
draw: { model: 'wood', freq: 520, dur: 0.06, gain: 0.11 }, // 抽牌:短促击木
discard: { model: 'wood', freq: 300, dur: 0.07, gain: 0.1, glideTo: 210 },
shuffle: { model: 'noise', dur: 0.32, gain: 0.13, bright: 2.2 }, // 洗牌:带通噪声扫
gold: { model: 'bell', freq: 880, dur: 0.22, gain: 0.13, partials: 3, glideTo: 1174, bright: 4 }, // 得金:铃磬亮
buy: { model: 'bell', freq: 587, dur: 0.16, gain: 0.13, partials: 2, bright: 2.6 },
remove: { model: 'drum', freq: 300, dur: 0.18, gain: 0.14, glideTo: 120, noise: 0.3, bright: 1.2 }, // 删卡:下沉
reward: { model: 'bell', freq: 523, dur: 0.3, gain: 0.15, partials: 3, glideTo: 784, bright: 3.2 }, // 翻牌/得卡:上扬磬
relic: { model: 'bell', freq: 415, dur: 0.6, gain: 0.16, partials: 4, glideTo: 622, bright: 3.5 }, // 得遗物:悠长神秘磬鸣
status: { model: 'wood', freq: 370, dur: 0.1, gain: 0.1, noise: 0.2 }, // 施加词缀(易伤/虚弱/灼)
tap: { model: 'wood', freq: 440, dur: 0.04, gain: 0.08 }, // UI 点按
select: { model: 'pluck', freq: 587, dur: 0.1, gain: 0.1, detune: 4, bright: 2.2 }, // 选中卡
zhoutian: { model: 'chord', freqs: [392, 494, 587, 784], dur: 0.7, gain: 0.2, gong: true }, // 周天:锣鸣磬和弦爆发
win: { model: 'chord', freqs: [523, 659, 784, 1046], dur: 0.95, gain: 0.2 }, // 胜利:上行磬大和弦
lose: { model: 'chord', freqs: [220, 262, 311], dur: 1.05, gain: 0.18, bright: 1.4 }, // 失败:低沉磬和弦
};
/** 事件谱要求覆盖的 SFX 名(门对账:battle/run 事件流每类都能出声)。 */
export const SFX_NAMES = Object.keys(SFX_SPEC);
/**
* 音色设计留痕(clean-room:纯合成,无外采;每音族对应的国风器物意象 + 覆盖事件)。
* 与 SFX_SPEC.model 同源;真机听感调校以此为设计意图基准。
*/
export const SOUND_DESIGN = {
bell: { 器物: '磬/钟/铃(金石之声)', 合成: '2–4 枚非谐泛音叠加 + 快 attack 长指数衰减', 覆盖: '金符/得能/得金/买/翻牌/得遗物/胜负' },
pluck: { 器物: '古琴/古筝(拨弦)', 合成: '基频 + 3 谐波 + 微失谐 + 渐闭低通', 覆盖: '木火水符/选中' },
drum: { 器物: '鼓(膜鸣)', 合成: '正弦快速掉频 + 起手噪声瞬态 + 低通', 覆盖: '土符/受击三强度/中招/删卡' },
wood: { 器物: '木鱼(击木)', 合成: '短噪瞬态 + 极短正弦体', 覆盖: '抽/弃/词缀/点按' },
noise: { 器物: '水声/翻纸(噪)', 合成: '白噪过带通频扫', 覆盖: '洗牌' },
chord: { 器物: '锣鸣/磬和弦', 合成: '多音琶音磬,周天叠噪声锣鸣涌起', 覆盖: '周天/胜/负' },
};
/**
* 建 SFX 合成器。
* @param {Object} [opts]
@ -94,58 +115,136 @@ export function createSfx(opts = {}) {
return ctx;
}
/** 一段白噪 buffer(受击颗粒/洗牌;惰性建一次复用)。 */
/** 一段白噪 buffer(受击颗粒/洗牌/水声;惰性建一次复用)。 */
function getNoise() {
if (noiseBuf || !ctx) return noiseBuf;
const len = Math.floor(ctx.sampleRate * 0.3);
const len = Math.floor(ctx.sampleRate * 0.5);
noiseBuf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
return noiseBuf;
}
/** 单振荡器一声(带 AR 包络;glide 可选频率滑动)。 */
function tone(freq, spec) {
if (!ctx || !master) return;
const t0 = ctx.currentTime;
const dur = spec.dur || 0.12;
const osc = ctx.createOscillator();
osc.type = spec.type === 'noise' || spec.type === 'chord' ? 'sine' : spec.type;
osc.frequency.setValueAtTime(freq, t0);
if (spec.glideTo) osc.frequency.exponentialRampToValueAtTime(spec.glideTo, t0 + dur);
/** AR 包络增益节点(快 attack 指数,指数 release;peak 峰值,dur 总时长)。 */
function envGain(t0, dur, peak, attack = 0.006) {
const g = ctx.createGain();
const peak = (spec.gain || 0.15);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.006); // 快 attack
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); // 指数 release
osc.connect(g); g.connect(master);
osc.start(t0); osc.stop(t0 + dur + 0.02);
// 混入噪声颗粒(受击)。
if (spec.noise) noiseBurst(spec.dur, (spec.gain || 0.15) * spec.noise);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
return g;
}
/** 噪声一击(洗牌/受击颗粒)。 */
function noiseBurst(dur, gain) {
if (!ctx || !master) return;
/** 单振荡器音(pluck 谐波/bell 泛音的构件);freq 可带 glide。 */
function partial(type, freq, t0, dur, peak, dest, glideTo) {
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (glideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), t0 + dur);
const g = envGain(t0, dur, peak);
osc.connect(g); g.connect(dest);
osc.start(t0); osc.stop(t0 + dur + 0.03);
}
/** 噪声瞬态一击(受击颗粒/木鱼起手/水声;过低通收高频)。 */
function noiseBurst(t0, dur, gain, cutoff = 2200, type = 'lowpass') {
const buf = getNoise();
if (!buf) return;
const t0 = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = buf;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + (dur || 0.2));
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2200;
src.connect(lp); lp.connect(g); g.connect(master);
src.start(t0); src.stop(t0 + (dur || 0.2) + 0.02);
const g = envGain(t0, dur, gain, 0.002);
const filt = ctx.createBiquadFilter(); filt.type = type; filt.frequency.value = cutoff;
src.connect(filt); filt.connect(g); g.connect(master);
src.start(t0); src.stop(t0 + dur + 0.03);
return filt;
}
/** 多音叠置(周天/胜负 stinger;琶音式微错峰)。 */
function chord(freqs, spec) {
if (!ctx) return;
freqs.forEach((f, i) => {
setTimeout(() => tone(f, { type: 'triangle', dur: spec.dur || 0.6, gain: (spec.gain || 0.2) / Math.max(1, freqs.length - i * 0.3) }), i * 45);
/** 磬/钟:2–4 枚非谐泛音叠加(金石之声,厚而清)。 */
function bell(spec, t0) {
const n = Math.max(1, spec.partials || 3);
const RATIOS = [1, 2.76, 5.4, 8.93]; // 磬类非谐比(区别纯谐波的"电子感")
const bright = spec.bright || 3;
for (let i = 0; i < n; i++) {
const f = spec.freq * RATIOS[i];
if (f > 16000) continue;
const peak = (spec.gain || 0.14) / (i + 1.2);
partial(i === 0 ? 'triangle' : 'sine', f, t0, spec.dur * (1 - i * 0.12), peak, master, i === 0 ? spec.glideTo : undefined);
}
if (bright < 2) partial('sine', spec.freq * 0.5, t0, spec.dur, (spec.gain || 0.14) * 0.4, master); // 低亮加下八度垫底
}
/** 古琴/筝拨弦:基频 + 3 谐波,微失谐加厚,渐闭低通。 */
function pluck(spec, t0) {
const filt = ctx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.setValueAtTime(spec.freq * (spec.bright || 2) * 2.4, t0);
filt.frequency.exponentialRampToValueAtTime(spec.freq * 1.2, t0 + spec.dur); // 拨弦渐暗
filt.connect(master);
const det = (spec.detune || 4) / 1200;
for (const [h, w, a] of [[1, 'sawtooth', 1], [2, 'sine', 0.5], [3, 'sine', 0.28]]) {
partial(w, spec.freq * h * (1 + det * (h - 1)), t0, spec.dur * (1 - (h - 1) * 0.15), (spec.gain || 0.15) * a, filt);
}
if (spec.noise) noiseBurst(t0, 0.04, (spec.gain || 0.15) * spec.noise, spec.freq * 4, 'bandpass');
}
/** 鼓:正弦快速掉频(膜鸣)+ 起手噪声瞬态 + 低通。 */
function drum(spec, t0) {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(spec.freq, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(30, spec.glideTo || spec.freq * 0.5), t0 + spec.dur * 0.7);
const g = envGain(t0, spec.dur, spec.gain || 0.2, 0.003);
const filt = ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = spec.freq * (spec.bright || 1.2) * 4;
osc.connect(g); g.connect(filt); filt.connect(master);
osc.start(t0); osc.stop(t0 + spec.dur + 0.03);
if (spec.noise) noiseBurst(t0, spec.dur * 0.5, (spec.gain || 0.2) * spec.noise, spec.freq * 6);
}
/** 木鱼:短噪瞬态 + 极短正弦体(击木)。 */
function wood(spec, t0) {
partial('sine', spec.freq, t0, Math.min(spec.dur, 0.07), (spec.gain || 0.1) * 0.8, master, spec.glideTo);
noiseBurst(t0, Math.min(spec.dur, 0.045), (spec.gain || 0.1) * (spec.noise != null ? spec.noise + 0.5 : 0.9), spec.freq * 3, 'bandpass');
}
/** 带通噪声扫(洗牌/水声):低通截止随时间上扫再落。 */
function noiseSweep(spec, t0) {
const buf = getNoise();
if (!buf) return;
const src = ctx.createBufferSource();
src.buffer = buf;
const filt = ctx.createBiquadFilter(); filt.type = 'bandpass';
filt.frequency.setValueAtTime(600, t0);
filt.frequency.linearRampToValueAtTime(600 * (spec.bright || 2.2), t0 + spec.dur * 0.6);
filt.frequency.linearRampToValueAtTime(500, t0 + spec.dur);
filt.Q.value = 1.2;
const g = envGain(t0, spec.dur, spec.gain || 0.13, 0.02);
src.connect(filt); filt.connect(g); g.connect(master);
src.start(t0); src.stop(t0 + spec.dur + 0.03);
}
/** 磬和弦(周天/胜负 stinger):多音琶音磬;gong=true 叠噪声锣鸣涌起。 */
function chord(spec, t0) {
(spec.freqs || [spec.freq]).forEach((f, i) => {
bell({ freq: f, dur: spec.dur * (1 - i * 0.08), gain: (spec.gain || 0.2) / (1 + i * 0.25), partials: 3, bright: spec.bright || 3, glideTo: undefined }, t0 + i * 0.05);
});
if (spec.gong) {
// 锣鸣:噪声经带通缓起缓落 + 低频涌动
const filt = noiseBurst(t0, spec.dur, (spec.gain || 0.2) * 0.5, 1600, 'bandpass');
if (filt) { filt.Q.value = 0.8; filt.frequency.setValueAtTime(400, t0); filt.frequency.linearRampToValueAtTime(2000, t0 + spec.dur * 0.4); }
partial('sine', (spec.freqs ? spec.freqs[0] : 392) * 0.5, t0, spec.dur, (spec.gain || 0.2) * 0.4, master);
}
}
/** 按 model 分发合成(渲染层事件分发调 play → 此;未知模型 no-op)。 */
function render(spec, t0) {
switch (spec.model) {
case 'bell': bell(spec, t0); break;
case 'pluck': pluck(spec, t0); break;
case 'drum': drum(spec, t0); break;
case 'wood': wood(spec, t0); break;
case 'noise': noiseSweep(spec, t0); break;
case 'chord': chord(spec, t0); break;
default: bell(spec, t0); // 兜底磬音,不哑火
}
}
/** 按名发声(渲染层事件分发调它;未知名 no-op)。 */
@ -155,18 +254,16 @@ export function createSfx(opts = {}) {
const spec = SFX_SPEC[name];
if (!spec) return;
plays++;
if (spec.type === 'noise') noiseBurst(spec.dur, spec.gain);
else if (spec.type === 'chord') chord(spec.freqs, spec);
else tone(spec.freq, spec);
render(spec, ctx.currentTime);
}
return {
/** 事件音效(SFX_SPEC 名)。 */
play,
/** 链环点亮第 n 环音高递升(八级);返回本环频率供渲染层记谱/机验。 */
/** 链环点亮第 n 环音高递升(八级);M4 换厚磬音(三泛音);返回本环频率供渲染层记谱/机验。 */
chainStep(n) {
const f = chainFreq(n);
if (enabled && ensureCtx()) { plays++; tone(f, { type: 'triangle', dur: 0.14, gain: 0.15 }); }
if (enabled && ensureCtx()) { plays++; bell({ freq: f, dur: 0.26, gain: 0.15, partials: 3, bright: 3 }, ctx.currentTime); }
return f;
},
/** 用户手势解锁(浏览器 AudioContext suspended → resume)。 */

View File

@ -1,5 +1,5 @@
/**
* src/scenes/run-scene.js —— 《山海巡异录》一局场景(M1 四视图 → M2 S11 七视图:+商店/删卡/Boss 遗物三选一)
* src/scenes/run-scene.js —— 《山海巡异录》一局场景(M1 四视图 → M2 七视图 → M4 青绿卷轴国风精致化)
* owner:北极星顶级线③(M1;吸收并取代 M0 battle-scene.js,后者随本单退役、M0 版见 git)
*
* 【渲染层铁律】只消费 run-core 的 readState()+takeEvents(),不含任何规则数值(§7.2 演出与逻辑解耦)。
@ -9,8 +9,12 @@
* (布局层唯一坐标事实)——不给单个对象挂 setInteractive,避免双派发;真指针点卡可选中、再点打出,
* M0「手牌区盖板吃掉点击」的缺陷随之消除。
*
* 【M1 视觉】仍零素材程序绘制(卡框/节点圆/符文敌身;插画位留空待风格版 §9.1);
* 状态签名变更整屏重建(M1 规模 ≤~30 对象,无性能压力;增量重绘随 M4 打磨)。
* 【M4 视觉】皮肤基元收敛在 src/ui/skin.js(卷轴册页/描金勾线/宝珠/五行珠/铜章/卡框)——去「方方正正」:
* 满幅山水背景(bg-scene/bg-result)+ 册页面板(ui-bg-panel/hud)+ 稀有度卡框(ui-frame-*)+ 生成 UI
* chrome(灵力宝珠/节点铜章/题头匾额/卷轴横条,松散透明件)+ 程序描金(按钮/血槽/五行珠)。真素材
* 未就绪(headless/mock/首屏)时同名基元退回程序绘制,不缺件、不抛异常。绘制锚点严格对齐 layout.js
* 命中靶——美化不平移可点区域。像素观感真机 mini-desktop 验(红线④)。
* 状态签名变更整屏重建(M4 规模 ≤~60 对象,无性能压力)。
*/
'use strict';
@ -24,13 +28,19 @@ import {
import {
PRES, computeSettlementPlan, sfxForEvent, elementKey, dragOutcome, tutorialStep, tutorialAdvance,
} from '../ui/presentation.js';
import { UI_FRAMES, preloadAtlases, buildFrameIndex } from '../ui/atlas.js';
import { UI_FRAMES, preloadAtlases, preloadUiChrome, buildFrameIndex } from '../ui/atlas.js';
import { createSfx } from '../audio/sfx.js';
import { createBgm, trackForScene } from '../audio/bgm.js';
// M4 青绿卷轴皮肤基元(渲染单源;真素材优先、程序兜底)。
import * as skin from '../ui/skin.js';
import { hx, GOLD, GOLD_DK, GOLD_TEXT, INK_TEXT, CREAM, SHIQING, SHILV, ZHUSHA, ZHESHI, CIHUANG, INK2 } from '../ui/skin.js';
// 节点型 → 显示字/色(表现层词汇,规则在 map-core/tables)。
const NODE_GLYPH = { battle: '战', elite: '精', rest: '憩', treasure: '宝', boss: '王', shop: '市', event: '遇' };
const NODE_COLOR = { battle: 0x3f7fbf, elite: 0xc98f3b, rest: 0x3fa34d, treasure: 0xf0c674, boss: 0xd9534f, shop: 0xb9a3d0, event: 0x7fd4c1 };
// 背景选图(局面 → bg 松散纹理键;缺则 skin 退渐变)。
const BG_SCENE_A = 'bg-scene-01';
const BG_SCENE_B = 'bg-scene-02';
/**
* 建一局场景(Phaser scene 配置对象;`this`=Phaser.Scene)。
@ -71,12 +81,12 @@ export function createRunScene(api) {
return {
key: 'shanhai-run',
/** 预加载图集(real 通道;mock/headless 无 scene.load → no-op,渲染层降级程序绘制)。 */
preload() { preloadAtlases(this); },
/** 预加载图集 + 松散 UI chrome(real 通道;mock/headless 无 scene.load → no-op,渲染层降级程序绘制)。 */
preload() { preloadAtlases(this); preloadUiChrome(this); },
create() {
const scene = this;
scene.cameras.main.setBackgroundColor('#141420');
scene.cameras.main.setBackgroundColor('#12130f');
frames = buildFrameIndex(scene); // 图集帧索引(未就绪→程序占位)
tutorialOn = !!(api.isTutorial && api.isTutorial()); // 首局首战引导(工厂告知)
if (api.debug) { api.debug.atlasReady = frames.ready; api.debug.atlasFrames = frames.count; }
@ -139,6 +149,8 @@ export function createRunScene(api) {
uiNodes = [];
}
function keep(scene, obj) { uiNodes.push(obj); return obj; }
/** 建皮肤绘制层上下文(捕获当前 frames;keep 收窄为单参供 skin 基元用)。 */
function layerFor(scene) { return skin.makeLayer(scene, (o) => keep(scene, o), frames); }
// ── M4 输入手势(手牌拖拽打出;语义仍走 api.route 单源,driver/CDP 经 injectInput 不经此路)──
function onPointerDown(p) {
@ -195,7 +207,8 @@ export function createRunScene(api) {
if (!total || total <= 0) return;
screenShake(scene);
const eCy = VIEW_H * ZONES.enemyZone / 2 + 14;
const t = scene.add.text(VIEW_W / 2, eCy, `${total}`, { fontSize: `${PRES.burstFontPx}px`, color: '#ffd76b', fontFamily: 'sans-serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(60);
const t = scene.add.text(VIEW_W / 2, eCy, `${total}`, { fontSize: `${PRES.burstFontPx}px`, color: '#ffd76b', fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(60);
t.setStroke('#7a3410', 5);
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.35 }, duration: PRES.burstShakeMs, yoyo: true, onComplete: () => scene.tweens.add({ targets: t, alpha: 0, duration: 200, onComplete: () => t.destroy() }) });
bumpFx(false);
}
@ -210,6 +223,12 @@ export function createRunScene(api) {
}
if (api.debug) api.debug.lastParticles = cap;
}
/** 出牌飞行:元素色小灵符自手牌区飞向敌身,缓动淡出(§7.2 卡牌飞行;演出层自毁)。 */
function flyToken(scene, fromX, fromY, color) {
const eCy = VIEW_H * ZONES.enemyZone / 2 + 14;
const tok = scene.add.circle(fromX, fromY, 7, color).setDepth(52).setStrokeStyle(2, GOLD);
scene.tweens.add({ targets: tok, x: VIEW_W / 2, y: eCy, scale: { from: 1, to: 0.4 }, alpha: { from: 1, to: 0.2 }, duration: PRES.cardFlyMs, ease: 'Cubic.In', onComplete: () => tok.destroy() });
}
/** 图集插画(帧就绪→image;未就绪→null,调用方降级程序绘制)。 */
function art(scene, x, y, id, dispW) {
const key = frames.atlasOf(id);
@ -220,14 +239,17 @@ export function createRunScene(api) {
}
/**
* 文本辅助——必须是 function 声明(提升)。
* 文本辅助——必须是 function 声明(提升)。depth 默认 12,压在背景(-20)与面板/内容(0)之上、演出层(40+)之下。
* 【2026-07-06 战斗全空事故根因】本函数曾写成 `const txt = (...) => ...` 且位于工厂 return 之后:
* 该行永不执行,`txt` 绑定永陷 TDZ,首次 rebuild 即抛 ReferenceError——整屏绘制(地图/战斗/商店)
* 全部无声失败,只有不依赖 txt 的事件浮字(floatText,function 声明)可见,浏览器像素复验才暴露
* (headless 门不建场景,验不到)。防回潮:scripts/scene-smoke.mjs mock 场景重放门 + rebuild 自证计数。
* 该行永不执行,`txt` 绑定永陷 TDZ,首次 rebuild 即抛 ReferenceError——整屏绘制全部无声失败。
* 防回潮:scripts/scene-smoke.mjs mock 场景重放门 + rebuild 自证计数。
*/
function txt(scene, x, y, str, size, color, origin = 0.5) {
return keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'sans-serif' }).setOrigin(origin));
return keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'sans-serif' }).setOrigin(origin).setDepth(12));
}
/** 卷轴文本(serif 描金/墨字,做标题与要素;与 txt 同 depth 层)。 */
function stxt(scene, x, y, str, size, color, origin = 0.5, weight = 'bold') {
return keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'serif', fontStyle: weight }).setOrigin(origin).setDepth(12));
}
function rebuild(scene, s) {
@ -249,110 +271,135 @@ export function createRunScene(api) {
}
}
/** 顶栏(全视图常驻):幕/金币/卡组/遗物。 */
/** 顶栏卷轴条(全视图常驻):幕/金币/卡组/遗物,统一图钉排布在描金横条上。 */
function drawTopStrip(scene, s) {
txt(scene, 10, 8, `第${s.act}幕`, 13, '#c9c3b2', 0);
txt(scene, 70, 8, `金 ${s.gold}`, 13, '#f0c674', 0);
txt(scene, 140, 8, `牌 ${s.deckCount}`, 13, '#9fc4e8', 0);
txt(scene, 200, 8, s.relics.map((r) => r.name).join(' · ') || '—', 12, '#b9a3d0', 0);
const L = layerFor(scene);
skin.hudBanner(L, VIEW_W / 2, 16, VIEW_W - 6, 30);
skin.statChip(L, 16, 16, '幕', `第${s.act}`, ZHESHI, { color: INK_TEXT });
skin.statChip(L, 92, 16, '金', s.gold, CIHUANG, { color: INK_TEXT });
skin.statChip(L, 166, 16, '牌', s.deckCount, SHIQING, { color: INK_TEXT });
const relicTxt = s.relics.map((r) => r.name).join(' · ') || '—';
keep(scene, scene.add.text(238, 16, relicTxt, { fontSize: '11px', color: INK_TEXT, fontFamily: 'serif', wordWrap: { width: 148 } }).setOrigin(0, 0.5).setDepth(12));
}
// ── 地图视图 ──────────────────────────────────────────────────────────────────
function drawMap(scene, s) {
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.42);
drawTopStrip(scene, s);
txt(scene, VIEW_W / 2, 40, `山海图 · 第${s.act}幕`, 20, '#e8e2d0');
txt(scene, VIEW_W / 2, 64, `巡异使 ${s.hero.hp}/${s.hero.hpMax}`, 14, '#bfe3bf');
skin.plaque(L, VIEW_W / 2, 48, `山海图 · 第${s.act}幕`, { w: 220, h: 46, size: 20 });
stxt(scene, VIEW_W / 2, 78, `巡异使 ${s.hero.hp}/${s.hero.hpMax}`, 14, '#bfe3bf');
const g = keep(scene, scene.add.graphics());
// 边(先画,压在节点下)。
g.lineStyle(2, 0x3a3a4c, 1);
const g = keep(scene, scene.add.graphics().setDepth(1));
// 边(先画,压在节点下):描金细线连脉络。
g.lineStyle(2, GOLD_DK, 0.55);
const centers = new Map();
for (const nd of s.map.nodes) centers.set(nd.id, mapNodeCenter(nd.floor, nd.idx, nd.width));
for (const [a, b] of s.map.edges) {
const ca = centers.get(a); const cb = centers.get(b);
g.lineBetween(ca.x, ca.y, cb.x, cb.y);
}
// 节点。
// 节点铜章。
for (const nd of s.map.nodes) {
const c = centers.get(nd.id);
const selectable = s.map.selectable.includes(nd.id);
const here = s.map.position === nd.id;
const base = keep(scene, scene.add.circle(c.x, c.y, 24, nd.visited && !here ? 0x2a2a38 : NODE_COLOR[nd.type] || 0x555566));
base.setStrokeStyle(selectable ? 3 : 1, selectable ? 0xf0c674 : 0x555566);
txt(scene, c.x, c.y, NODE_GLYPH[nd.type] || '?', 15, nd.visited && !here ? '#6a6478' : '#141420');
if (here) keep(scene, scene.add.circle(c.x, c.y, 30).setStrokeStyle(2, 0x9fe08a));
const state = here ? 'here' : selectable ? 'selectable' : nd.visited ? 'visited' : 'locked';
const { ring } = skin.nodeMedallion(L, c.x, c.y, 24, NODE_GLYPH[nd.type] || '?', NODE_COLOR[nd.type] || 0x555566, { state });
if (here) keep(scene, scene.add.circle(c.x, c.y, 31).setStrokeStyle(2, 0x9fe08a).setDepth(11));
if (selectable) {
const ring = keep(scene, scene.add.circle(c.x, c.y, 27).setStrokeStyle(2, 0xf0c674));
scene.tweens.add({ targets: ring, alpha: { from: 1, to: 0.25 }, duration: 620, yoyo: true, repeat: -1 });
const pulse = keep(scene, scene.add.circle(c.x, c.y, 29).setStrokeStyle(2, GOLD).setDepth(11));
scene.tweens.add({ targets: pulse, alpha: { from: 1, to: 0.2 }, scale: { from: 1, to: 1.12 }, duration: 640, yoyo: true, repeat: -1 });
}
}
txt(scene, VIEW_W / 2, VIEW_H - 22, '点亮的节点 · 点按前往', 13, '#8a8498');
stxt(scene, VIEW_W / 2, VIEW_H - 20, '点亮铜章 · 循脉前行', 13, '#e8dcc0');
}
// ── 战斗视图(M0 battle-scene 语义迁入 + 链轨完整化)─────────────────────────
// ── 战斗视图 ─────────────────────────────────────────────────────────────────
function drawBattle(scene, s) {
const b = s.battle;
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_A, VIEW_W, VIEW_H, 0.5);
drawTopStrip(scene, s);
const eZoneH = VIEW_H * ZONES.enemyZone;
const my = midY();
// 敌区。
const tierTag = b.enemy.tier === 'boss' ? '〔王〕' : b.enemy.tier === 'elite' ? '〔精〕' : '';
// 敌区:名号匾额 + 立绘 + 血槽 + 意图徽 + 词缀。
const tierTag = b.enemy.tier === 'boss' ? '王 · ' : b.enemy.tier === 'elite' ? '精 · ' : '';
const phaseTag = b.enemy.phase2 ? ' · 相位二' : '';
txt(scene, VIEW_W / 2, 34, `${tierTag}${b.enemy.name}${b.enemy.strength > 0 ? ` 力+${b.enemy.strength}` : ''}${b.enemy.block > 0 ? ` 甲${b.enemy.block}` : ''}${phaseTag}`, 19, '#e8e2d0');
skin.plaque(L, VIEW_W / 2, 40, `${tierTag}${b.enemy.name}${phaseTag}`, { w: 236, h: 40, size: 17 });
const augs = [];
if (b.enemy.strength > 0) augs.push(`力+${b.enemy.strength}`);
if (b.enemy.block > 0) augs.push(`甲${b.enemy.block}`);
if (augs.length) stxt(scene, VIEW_W / 2, 60, augs.join(' '), 12, hx(CIHUANG));
drawEnemyBody(scene, s, eZoneH);
keep(scene, scene.add.rectangle(VIEW_W / 2, eZoneH - 58, 240, 12, 0x333344).setOrigin(0.5));
keep(scene, scene.add.rectangle(VIEW_W / 2 - 120, eZoneH - 58, 240 * (b.enemy.hp / b.enemy.hpMax), 12, 0xd9534f).setOrigin(0, 0.5));
txt(scene, VIEW_W / 2, eZoneH - 40, `${b.enemy.hp} / ${b.enemy.hpMax}`, 13, '#c9c3b2');
txt(scene, VIEW_W / 2, 66, b.enemy.intent ? intentLabel(b.enemy.intent) : '', 16, '#f0c674');
// 意图徽(去纯文字)。
if (b.enemy.intent) drawIntent(L, VIEW_W / 2, 76, b.enemy.intent);
// 敌血槽(描金框)。
skin.bar(L, VIEW_W / 2, eZoneH - 54, 250, 14, b.enemy.hp / b.enemy.hpMax, ZHUSHA);
stxt(scene, VIEW_W / 2, eZoneH - 54, `${b.enemy.hp} / ${b.enemy.hpMax}`, 12, '#fbeede');
// M2 敌词缀行(易伤/虚弱/灼;有值才显)。
const st = b.enemy.statuses || {};
const stParts = [];
if (st.vulnerable > 0) stParts.push(`易伤${st.vulnerable}`);
if (st.weak > 0) stParts.push(`虚弱${st.weak}`);
if (st.burn > 0) stParts.push(`灼${st.burn}`);
if (stParts.length) txt(scene, VIEW_W / 2, 88, stParts.join(' · '), 13, '#e2a13e');
if (stParts.length) stxt(scene, VIEW_W / 2, eZoneH - 34, stParts.join(' · '), 13, hx(CIHUANG));
// 中段:我方状态 + 能量 + 链轨(五槽+倍率常驻,§7.1 一屏五要素)+ END。
txt(scene, 12, my - 26, `巡异使 ${b.hero.hp}/${b.hero.hpMax}${b.hero.block > 0 ? ` 甲${b.hero.block}` : ''}${b.hero.strength > 0 ? ` 力+${b.hero.strength}` : ''}${b.hero.toughness > 0 ? ` 韧+${b.hero.toughness}` : ''}`, 14, '#bfe3bf', 0);
keep(scene, scene.add.circle(34, my + 16, 20, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
txt(scene, 34, my + 16, `${b.energy}/${b.energyMax}`, 15, '#ffffff');
const litSet = new Set(b.chain.seq);
ELEMENT_CYCLE.forEach((el, i) => {
const x = VIEW_W / 2 - 70 + i * 35;
const lit = litSet.has(el);
keep(scene, scene.add.circle(x, my + 14, 12, lit ? ELEMENT_COLORS[el] : 0x2a2a38).setStrokeStyle(2, ELEMENT_COLORS[el]));
txt(scene, x, my + 14, el, 12, lit ? '#141420' : '#9a94a8');
// 延链指引:下一相生位描亮(链轨完整化,driver 与玩家同一信息)。
if (b.chain.nextElement === el && !b.chain.zhoutianUsed) {
keep(scene, scene.add.circle(x, my + 14, 16).setStrokeStyle(2, 0x9fe08a));
}
});
txt(scene, VIEW_W / 2 + 118, my + 14, b.chain.links > 0 || b.chain.seq.length > 0 ? `×${(1 + b.chain.links * 0.25).toFixed(2)}` : '', 16, '#f0c674');
txt(scene, VIEW_W / 2, my - 26, `第 ${b.turn} 回合`, 13, '#8a8498');
// 中段:我方状态条 + 能量宝珠 + 链轨(五行珠+倍率)+ 结束按钮。
const heroLine = `巡异使 ${b.hero.hp}/${b.hero.hpMax}${b.hero.block > 0 ? ` 甲${b.hero.block}` : ''}${b.hero.strength > 0 ? ` 力+${b.hero.strength}` : ''}${b.hero.toughness > 0 ? ` 韧+${b.hero.toughness}` : ''}`;
stxt(scene, 60, my - 24, heroLine, 13, '#cdeccd', 0.5);
// 能量宝珠。
skin.orb(L, 34, my + 16, 19);
stxt(scene, 34, my + 16, `${b.energy}/${b.energyMax}`, 14, '#ffffff');
// 链轨:五行珠。
drawChainTrack(L, scene, b, my);
stxt(scene, VIEW_W / 2 + 120, my + 14, b.chain.links > 0 || b.chain.seq.length > 0 ? `×${(1 + b.chain.links * 0.25).toFixed(2)}` : '', 16, hx(GOLD));
stxt(scene, VIEW_W - 46, my - 20, `第 ${b.turn} 回合`, 11, '#d8ccaf');
const end = endTurnRect();
keep(scene, scene.add.rectangle(end.x, end.y, end.w, end.h, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b));
txt(scene, end.x, end.y, '结束', 15, '#f5e9d5');
skin.textButton(L, end.x, end.y, end.w, end.h, '结束', { variant: 'primary', size: 15 });
// 手牌(点选/上滑两式;selected 从 run 状态来——真指针与 driver 同源)。选中卡浮起放大 1.55×(§7.2 条1)。
const n = b.hand.length;
b.handCards.forEach((card, i) => {
const c = handCardCenter(i, n);
const selected = s.ui.selected === i;
drawCardFront(scene, c.x, c.y - (selected ? PRES.selectLiftPx : 0), card, { selected, affordable: card.cost <= b.energy, scale: selected ? PRES.hoverScale : 1.0 });
skin.cardFront(L, c.x, c.y - (selected ? PRES.selectLiftPx : 0), card,
{ selected, affordable: card.cost <= b.energy, scale: selected ? PRES.hoverScale : 1.0 }, CARD_W, CARD_H, ELEMENT_COLORS);
});
if (s.ui.selected >= 0) txt(scene, VIEW_W / 2, handTop() + 8, '上滑打出 · 或点敌身 · 点空白回手', 12, '#8a8498');
if (s.ui.selected >= 0) stxt(scene, VIEW_W / 2, handTop() + 6, '上滑打出 · 或点敌身 · 点空白回手', 12, '#e8dcc0');
// 教学引导层(仅教学局;固定序覆盖提示 + 高亮,§ S20,纯覆盖不改逻辑)。
if (tutorialOn) drawTutorial(scene, s);
}
/** 链轨五行珠(点亮态发光、下一相生位脉动金环;坐标与 M0 同,r 略增)。 */
function drawChainTrack(L, scene, b, my) {
const litSet = new Set(b.chain.seq);
ELEMENT_CYCLE.forEach((el, i) => {
const x = VIEW_W / 2 - 70 + i * 35;
const lit = litSet.has(el);
const isNext = b.chain.nextElement === el && !b.chain.zhoutianUsed;
const { glow } = skin.gem(L, x, my + 14, 13, ELEMENT_COLORS[el], { lit, next: isNext });
keep(scene, scene.add.text(x, my + 14, el, { fontSize: '12px', color: lit ? '#141210' : '#cfc8b8', fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(12));
if (glow) scene.tweens.add({ targets: glow, alpha: { from: 1, to: 0.25 }, scale: { from: 1, to: 1.18 }, duration: 560, yoyo: true, repeat: -1 });
});
}
/** 敌意图小徽(攻/御/攻御/蓄)。 */
function drawIntent(L, x, y, it) {
if (it.kind === 'attack') { skin.intentBadge(L, x, y, 'attack', '⚔', `${it.n}${(it.times || 1) > 1 ? `×${it.times}` : ''}`); return; }
if (it.kind === 'block') { skin.intentBadge(L, x, y, 'block', '盾', `${it.n}`); return; }
if (it.kind === 'attackBlock') { skin.intentBadge(L, x, y, 'attackBlock', '⚔盾', `${it.n}`); return; }
skin.intentBadge(L, x, y, 'buff', '力', `+${it.strength}`);
}
/** 教学引导层(固定序;§ S20 首战):提示条 + 当前目标高亮。keep 纳入重建层,随屏刷新。 */
function drawTutorial(scene, s) {
if (!s.battle) return;
const step = tutorialStep(s.battle, s.ui.selected, tutorialDone);
if (api.debug) api.debug.tutorialStep = step ? step.id : null;
if (!step) return;
keep(scene, scene.add.rectangle(VIEW_W / 2, handTop() - 24, VIEW_W - 24, 42, 0x1a1420, 0.92).setStrokeStyle(2, 0xf0c674).setDepth(70));
keep(scene, scene.add.rectangle(VIEW_W / 2, handTop() - 24, VIEW_W - 24, 42, 0x1a1420, 0.92).setStrokeStyle(2, GOLD).setDepth(70));
keep(scene, scene.add.text(VIEW_W / 2, handTop() - 24, step.hint, { fontSize: '12px', color: '#f5e9d5', fontFamily: 'sans-serif', wordWrap: { width: VIEW_W - 44 }, align: 'center' }).setOrigin(0.5).setDepth(71));
const hl = tutorialHighlightRect(step.highlight);
if (hl) {
@ -367,250 +414,231 @@ export function createRunScene(api) {
if (kind === 'enemy') return { x: VIEW_W / 2, y: (VIEW_H * ZONES.enemyZone) / 2, w: VIEW_W - 40, h: VIEW_H * ZONES.enemyZone - 20 };
if (kind === 'endTurn') { const e = endTurnRect(); return { x: e.x, y: e.y, w: e.w + 8, h: e.h + 8 }; }
if (kind === 'chainTrack') return { x: VIEW_W / 2 - 5, y: my + 14, w: 200, h: 40 };
if (kind === 'enemyIntent') return { x: VIEW_W / 2, y: 66, w: 260, h: 28 };
if (kind === 'enemyIntent') return { x: VIEW_W / 2, y: 76, w: 260, h: 28 };
return null;
}
/** 绘敌身:图集立绘就绪→真敌兽立绘 + 呼吸缩放(§9.2);未就绪→按 tier 变形的程序几何降级。 */
/** 绘敌身:图集立绘就绪→真敌兽立绘 + 呼吸缩放(§9.2)+ 承脚台影;未就绪→按 tier 变形的程序几何降级。 */
function drawEnemyBody(scene, s, eZoneH) {
const b = s.battle;
const cx = VIEW_W / 2; const cy = eZoneH / 2 + 14;
const alive = b.enemy.hp > 0;
const scale = b.enemy.tier === 'boss' ? 1.5 : b.enemy.tier === 'elite' ? 1.2 : 1.0;
// 承脚台影(椭圆柔影,给立绘落地感;用 graphics.fillEllipse 避免依赖 scene.add.ellipse)。
const shadow = keep(scene, scene.add.graphics().setDepth(0));
shadow.fillStyle(0x000000, 0.28); shadow.fillEllipse(cx, cy + 60 * scale, 120 * scale, 22 * scale);
// 图集立绘(M4 S17)。
const artKey = frames.atlasOf(b.enemy.id);
if (artKey) {
const dim = 148 * scale;
const im = keep(scene, scene.add.image(cx, cy, artKey, b.enemy.id).setDisplaySize(dim, dim));
const im = keep(scene, scene.add.image(cx, cy, artKey, b.enemy.id).setDisplaySize(dim, dim).setDepth(1));
if (!alive) im.setAlpha(0.4);
else scene.tweens.add({ targets: im, scaleX: im.scaleX * 1.03, scaleY: im.scaleY * 1.03, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); // 呼吸(§9.2 程序动画)
if (b.enemy.block > 0) { const gg = keep(scene, scene.add.graphics()); gg.lineStyle(3, 0x7fb2e5, 0.9); gg.strokeCircle(cx, cy, 74 * scale); }
if (b.enemy.block > 0) { const gg = keep(scene, scene.add.graphics().setDepth(2)); gg.lineStyle(3, 0x7fb2e5, 0.9); gg.strokeCircle(cx, cy, 74 * scale); }
return;
}
const g = keep(scene, scene.add.graphics());
const g = keep(scene, scene.add.graphics().setDepth(1));
g.fillStyle(alive ? (b.enemy.phase2 ? 0x6e2e5f : 0x2e6e78) : 0x3a3a44, 1);
g.fillTriangle(cx, cy - 52 * scale, cx - 44 * scale, cy + 26 * scale, cx + 44 * scale, cy + 26 * scale);
g.fillTriangle(cx - 40 * scale, cy + 6, cx - 78 * scale, cy + 34, cx - 30 * scale, cy + 34);
g.fillTriangle(cx + 40 * scale, cy + 6, cx + 78 * scale, cy + 34, cx + 30 * scale, cy + 34);
g.fillStyle(0xf0c674, 1);
g.fillStyle(GOLD, 1);
g.fillCircle(cx - 12 * scale, cy - 12, 4); g.fillCircle(cx + 12 * scale, cy - 12, 4);
if (b.enemy.block > 0) { g.lineStyle(3, 0x7fb2e5, 0.9); g.strokeCircle(cx, cy, 64 * scale); }
}
// ── 奖励视图(战利三选一)──────────────────────────────────────────────────────
function drawReward(scene, s) {
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_A, VIEW_W, VIEW_H, 0.62);
drawTopStrip(scene, s);
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.55));
txt(scene, VIEW_W / 2, VIEW_H * 0.24, '战利 · 三选一', 24, '#f0c674');
skin.scrim(L, VIEW_W, VIEW_H, 0.35);
skin.plaque(L, VIEW_W / 2, VIEW_H * 0.22, '战利 · 三选一', { w: 220, h: 48, size: 22, color: hx(ZHUSHA) });
s.reward.options.forEach((card, i) => {
const c = rewardCardCenter(i, s.reward.options.length);
drawCardFront(scene, c.x, c.y, card, { selected: false, affordable: true, scale: 1.05 });
txt(scene, c.x, c.y + CARD_H / 2 + 16, card.rarity, 12, card.rarity === '稀有' ? '#f0c674' : card.rarity === '罕见' ? '#9fc4e8' : '#8a8498');
const cont = skin.cardFront(L, c.x, c.y, card, { selected: false, affordable: true, scale: 1.05 }, CARD_W, CARD_H, ELEMENT_COLORS);
scene.tweens.add({ targets: cont, y: { from: c.y + 16, to: c.y }, alpha: { from: 0, to: 1 }, duration: 220 + i * 60, ease: 'Back.Out' }); // 入场
stxt(scene, c.x, c.y + CARD_H / 2 + 16, card.rarity, 12, card.rarity === '稀有' ? hx(GOLD) : card.rarity === '罕见' ? '#9fc4e8' : '#cbbfa6');
});
const skip = rewardSkipRect();
keep(scene, scene.add.rectangle(skip.x, skip.y, skip.w, skip.h, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, skip.x, skip.y, '不取 · 前行', 14, '#c9c3b2');
skin.textButton(L, skip.x, skip.y, skip.w, skip.h, '不取 · 前行', { variant: 'ghost', size: 14 });
}
// ── Boss 遗物三选一视图(M2 S10)────────────────────────────────────────────────
function drawRelicReward(scene, s) {
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.62);
drawTopStrip(scene, s);
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.55));
txt(scene, VIEW_W / 2, VIEW_H * 0.24, '异宝 · 择一而取', 24, '#b9a3d0');
skin.scrim(L, VIEW_W, VIEW_H, 0.35);
skin.plaque(L, VIEW_W / 2, VIEW_H * 0.22, '异宝 · 择一而取', { w: 240, h: 48, size: 22, color: hx(0x7a4fa0) });
s.relicReward.options.forEach((r, i) => {
const c = rewardCardCenter(i, s.relicReward.options.length);
const g = keep(scene, scene.add.graphics());
g.fillStyle(0x241f33, 1); g.fillRoundedRect(c.x - 52, c.y - 75, 104, 150, 10);
g.lineStyle(2, 0xb9a3d0, 1); g.strokeRoundedRect(c.x - 52, c.y - 75, 104, 150, 10);
txt(scene, c.x, c.y - 48, r.name, 14, '#e8e2d0');
keep(scene, scene.add.text(c.x, c.y + 14, r.text, { fontSize: '10px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: 92 }, align: 'center' }).setOrigin(0.5));
skin.panel(L, c.x, c.y, 104, 150, { radius: 10 });
const artK = frames.atlasOf(r.id);
if (artK) keep(scene, scene.add.image(c.x, c.y - 30, artK, r.id).setDisplaySize(64, 64).setDepth(12));
stxt(scene, c.x, c.y + 18, r.name, 13, INK_TEXT);
keep(scene, scene.add.text(c.x, c.y + 40, r.text, { fontSize: '10px', color: INK_TEXT, fontFamily: 'sans-serif', wordWrap: { width: 92 }, align: 'center' }).setOrigin(0.5).setDepth(12));
});
const skip = rewardSkipRect();
keep(scene, scene.add.rectangle(skip.x, skip.y, skip.w, skip.h, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, skip.x, skip.y, '不取 · 前行', 14, '#c9c3b2');
skin.textButton(L, skip.x, skip.y, skip.w, skip.h, '不取 · 前行', { variant: 'ghost', size: 14 });
}
// ── 商店视图(M2 S11;坐标与 layout shopCard/shopRelic/按钮靶同源)───────────────
function drawShop(scene, s) {
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.52);
drawTopStrip(scene, s);
txt(scene, VIEW_W / 2, 40, '山市 · 以金易宝', 20, '#e8e2d0');
skin.plaque(L, VIEW_W / 2, 44, '山市 · 以金易宝', { w: 220, h: 44, size: 19 });
s.shop.cards.forEach((c, i) => {
const col = i % 3; const row = Math.floor(i / 3);
const x = 78 + col * 118; const y = VIEW_H * 0.24 + row * 165;
if (c.sold) {
const g = keep(scene, scene.add.graphics());
g.lineStyle(1, 0x4a4460, 1); g.strokeRoundedRect(x - 52, y - 75, 104, 150, 10);
txt(scene, x, y, '已易出', 13, '#6a6478');
skin.panel(L, x, y, 104, 150, { radius: 10, variant: 'dark', raster: false, alpha: 0.85 });
stxt(scene, x, y, '已易出', 13, '#7d7666');
return;
}
drawCardFront(scene, x, y, c, { selected: false, affordable: s.gold >= c.price, scale: 1.0 });
txt(scene, x, y + 88, `${c.price} 金`, 13, s.gold >= c.price ? '#f0c674' : '#8a8498');
skin.cardFront(L, x, y, c, { selected: false, affordable: s.gold >= c.price, scale: 1.0 }, CARD_W, CARD_H, ELEMENT_COLORS);
skin.statChip(L, x - 24, y + 86, '金', c.price, s.gold >= c.price ? CIHUANG : INK2, { color: s.gold >= c.price ? hx(GOLD) : '#8a8272' });
});
s.shop.relics.forEach((r, i) => {
const x = VIEW_W / 2 + (i - 0.5) * 150; const y = VIEW_H * 0.66;
const g = keep(scene, scene.add.graphics());
g.fillStyle(r.sold ? 0x1a1724 : 0x241f33, 1); g.fillRoundedRect(x - 65, y - 42, 130, 84, 8);
g.lineStyle(2, r.sold ? 0x4a4460 : 0xb9a3d0, 1); g.strokeRoundedRect(x - 65, y - 42, 130, 84, 8);
txt(scene, x, y - 22, r.sold ? '已易出' : r.name, 13, r.sold ? '#6a6478' : '#e8e2d0');
skin.panel(L, x, y, 130, 84, { radius: 8, variant: r.sold ? 'dark' : 'panel', raster: !r.sold });
stxt(scene, x, y - 22, r.sold ? '已易出' : r.name, 13, r.sold ? '#7d7666' : INK_TEXT);
if (!r.sold) {
keep(scene, scene.add.text(x, y + 2, r.text, { fontSize: '9px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: 118 }, align: 'center' }).setOrigin(0.5));
txt(scene, x, y + 30, `${r.price} 金`, 12, s.gold >= r.price ? '#f0c674' : '#8a8498');
keep(scene, scene.add.text(x, y + 2, r.text, { fontSize: '9px', color: INK_TEXT, fontFamily: 'sans-serif', wordWrap: { width: 118 }, align: 'center' }).setOrigin(0.5).setDepth(12));
stxt(scene, x, y + 30, `${r.price} 金`, 12, s.gold >= r.price ? hx(GOLD) : '#8a8272');
}
});
// 删卡/离开按钮(靶同源)。
const rx = VIEW_W / 2 - 80; const ry = VIEW_H * 0.82;
keep(scene, scene.add.rectangle(rx, ry, 140, 44, 0x5a2e3f).setStrokeStyle(2, s.shop.canRemove ? 0xd9698a : 0x555566));
txt(scene, rx, ry - 8, '销卡除杂', 14, s.shop.canRemove ? '#f5e9d5' : '#8a8498');
txt(scene, rx, ry + 10, `${s.shop.removePrice} 金`, 12, s.shop.canRemove ? '#f0c674' : '#8a8498');
skin.textButton(L, rx, ry, 140, 44, '销卡除杂', { variant: s.shop.canRemove ? 'danger' : 'ghost', enabled: s.shop.canRemove, size: 14, sub: `${s.shop.removePrice} 金`, subColor: hx(GOLD) });
const lx = VIEW_W / 2 + 90;
keep(scene, scene.add.rectangle(lx, ry, 120, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, lx, ry, '离市 · 前行', 14, '#c9c3b2');
skin.textButton(L, lx, ry, 120, 44, '离市 · 前行', { variant: 'jade', size: 14 });
}
// ── 删卡屏(M2 S11;网格与 layout removePick 靶同源)────────────────────────────
function drawShopRemove(scene, s) {
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.6);
drawTopStrip(scene, s);
txt(scene, VIEW_W / 2, 44, `销卡除杂 · ${s.shop.removePrice} 金`, 18, '#d9698a');
txt(scene, VIEW_W / 2, 68, '点选一张 · 永离卡组', 12, '#8a8498');
skin.plaque(L, VIEW_W / 2, 46, `销卡除杂 · ${s.shop.removePrice} 金`, { w: 250, h: 44, size: 18, color: hx(ZHUSHA) });
stxt(scene, VIEW_W / 2, 74, '点选一张 · 永离卡组', 12, '#e8dcc0');
s.shop.deckCards.forEach((c, i) => {
const col = i % 5; const row = Math.floor(i / 5);
const x = 47 + col * 74; const y = 120 + row * 106;
const g = keep(scene, scene.add.graphics());
g.fillStyle(0x241f33, 1); g.fillRoundedRect(x - 34, y - 48, 68, 96, 8);
g.lineStyle(2, ELEMENT_COLORS[c.element] || 0x555566, 1); g.strokeRoundedRect(x - 34, y - 48, 68, 96, 8);
txt(scene, x, y - 30, `${c.element}·${c.cost}费`, 10, '#9a94a8');
keep(scene, scene.add.text(x, y + 4, c.name, { fontSize: '11px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 60 }, align: 'center' }).setOrigin(0.5));
txt(scene, x, y + 36, c.rarity, 9, c.rarity === '稀有' ? '#f0c674' : c.rarity === '罕见' ? '#9fc4e8' : '#8a8498');
skin.panel(L, x, y, 68, 96, { radius: 8 });
const artK = frames.atlasOf(c.id);
if (artK) keep(scene, scene.add.image(x, y - 16, artK, c.id).setDisplaySize(52, 40).setDepth(12));
stxt(scene, x, y - 34, `${c.element}·${c.cost}`, 10, hx(ELEMENT_COLORS[c.element] || SHIQING));
keep(scene, scene.add.text(x, y + 12, c.name, { fontSize: '11px', color: INK_TEXT, fontFamily: 'serif', wordWrap: { width: 60 }, align: 'center' }).setOrigin(0.5).setDepth(12));
stxt(scene, x, y + 36, c.rarity, 9, c.rarity === '稀有' ? hx(GOLD) : c.rarity === '罕见' ? '#5a86b8' : '#8a8272');
});
const bx = VIEW_W / 2; const by = VIEW_H * 0.9;
keep(scene, scene.add.rectangle(bx, by, 140, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, bx, by, '不销 · 回市', 14, '#c9c3b2');
skin.textButton(L, VIEW_W / 2, VIEW_H * 0.9, 140, 44, '不销 · 回市', { variant: 'jade', size: 14 });
}
// ── 事件视图(M3 S16;选项按钮与 layout eventOption 靶同源)──────────────────────
function drawEvent(scene, s) {
const L = layerFor(scene);
skin.bgCover(L, BG_SCENE_A, VIEW_W, VIEW_H, 0.5);
drawTopStrip(scene, s);
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.4));
txt(scene, VIEW_W / 2, VIEW_H * 0.2, `奇遇 · ${s.event.name}`, 22, '#7fd4c1');
keep(scene, scene.add.text(VIEW_W / 2, VIEW_H * 0.34, s.event.text, { fontSize: '14px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 320 }, align: 'center' }).setOrigin(0.5));
skin.scrim(L, VIEW_W, VIEW_H, 0.3);
skin.plaque(L, VIEW_W / 2, VIEW_H * 0.18, `奇遇 · ${s.event.name}`, { w: 260, h: 48, size: 20, color: hx(0x2f7a68) });
skin.panel(L, VIEW_W / 2, VIEW_H * 0.34, 340, VIEW_H * 0.16, { radius: 12 });
keep(scene, scene.add.text(VIEW_W / 2, VIEW_H * 0.34, s.event.text, { fontSize: '14px', color: INK_TEXT, fontFamily: 'serif', wordWrap: { width: 310 }, align: 'center' }).setOrigin(0.5).setDepth(12));
s.event.options.forEach((o, i) => {
const y = VIEW_H * 0.52 + i * 64;
keep(scene, scene.add.rectangle(VIEW_W / 2, y, 320, 52, 0x24313a).setStrokeStyle(2, 0x7fd4c1));
keep(scene, scene.add.text(VIEW_W / 2, y, o.label, { fontSize: '13px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 300 }, align: 'center' }).setOrigin(0.5));
skin.textButton(L, VIEW_W / 2, y, 320, 52, o.label, { variant: 'jade', size: 13 });
});
}
// ── 局外三屏(M3 S15;坐标与 layout computeHomeTargets 同源)──────────────────────
function drawHome(scene, hs) {
const L = layerFor(scene);
const m = hs.home.meta;
txt(scene, VIEW_W / 2, VIEW_H * 0.12, '山海巡异录', 34, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.18, `出巡 ${m.runsPlayed} · 功成 ${m.wins}`, 13, '#8a8498');
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.4);
skin.plaque(L, VIEW_W / 2, VIEW_H * 0.11, '山海巡异录', { w: 280, h: 60, size: 32 });
stxt(scene, VIEW_W / 2, VIEW_H * 0.175, `出巡 ${m.runsPlayed} · 功成 ${m.wins}`, 13, '#e8dcc0');
// 职业二选(选中金框)。
for (const [i, c] of m.classes.entries()) {
const x = VIEW_W / 2 + (i === 0 ? -88 : 88);
const sel = hs.home.sel.class === c.id;
keep(scene, scene.add.rectangle(x, VIEW_H * 0.34, 150, 110, 0x241f33).setStrokeStyle(sel ? 3 : 1, sel ? 0xf0c674 : 0x555566));
txt(scene, x, VIEW_H * 0.31, c.name, 18, sel ? '#f0c674' : '#c9c3b2');
txt(scene, x, VIEW_H * 0.36, `气血 ${c.hpMax}`, 12, '#9fc4e8');
txt(scene, x, VIEW_H * 0.395, c.id === 'jia' ? '攻伐见长' : '灵脉见长', 11, '#8a8498');
skin.panel(L, x, VIEW_H * 0.34, 150, 110, { radius: 12, variant: sel ? 'panel' : 'dark', raster: sel });
if (sel) { const fr = keep(scene, scene.add.graphics().setDepth(11)); fr.lineStyle(3, GOLD, 1); fr.strokeRoundedRect(x - 75, VIEW_H * 0.34 - 55, 150, 110, 12); }
stxt(scene, x, VIEW_H * 0.31, c.name, 18, sel ? INK_TEXT : hx(GOLD));
stxt(scene, x, VIEW_H * 0.36, `气血 ${c.hpMax}`, 12, sel ? INK_TEXT : '#9fc4e8');
stxt(scene, x, VIEW_H * 0.395, c.id === 'jia' ? '攻伐见长' : '灵脉见长', 11, sel ? '#5a4a30' : '#cbbfa6');
}
// 进阶选择(加减 + 当前级文案)。
keep(scene, scene.add.rectangle(VIEW_W / 2 - 110, VIEW_H * 0.5, 52, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 - 110, VIEW_H * 0.5, '−', 22, '#e8e2d0');
keep(scene, scene.add.rectangle(VIEW_W / 2 + 110, VIEW_H * 0.5, 52, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 + 110, VIEW_H * 0.5, '+', 22, '#e8e2d0');
skin.textButton(L, VIEW_W / 2 - 110, VIEW_H * 0.5, 52, 44, '−', { variant: 'secondary', size: 22 });
skin.textButton(L, VIEW_W / 2 + 110, VIEW_H * 0.5, 52, 44, '+', { variant: 'secondary', size: 22 });
const asc = hs.home.sel.ascension;
txt(scene, VIEW_W / 2, VIEW_H * 0.48, `进阶 ${asc}${hs.home.maxAscension > 0 ? ` / 已启 ${hs.home.maxAscension}` : ''}`, 16, '#e2a13e');
txt(scene, VIEW_W / 2, VIEW_H * 0.52, asc > 0 ? (hs.home.ascensions[asc - 1] || {}).text || '' : '无修正 · 基准巡途', 11, '#8a8498');
stxt(scene, VIEW_W / 2, VIEW_H * 0.48, `进阶 ${asc}${hs.home.maxAscension > 0 ? ` / 已启 ${hs.home.maxAscension}` : ''}`, 16, hx(CIHUANG));
stxt(scene, VIEW_W / 2, VIEW_H * 0.52, asc > 0 ? (hs.home.ascensions[asc - 1] || {}).text || '' : '无修正 · 基准巡途', 11, '#cbbfa6');
// 出巡/每日。
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.62, 240, 56, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b));
txt(scene, VIEW_W / 2, VIEW_H * 0.62, '启程出巡', 20, '#f5e9d5');
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.72, 240, 48, 0x24313a).setStrokeStyle(2, 0x7fd4c1));
txt(scene, VIEW_W / 2, VIEW_H * 0.705, `每日一巡 · ${hs.home.daily.date}`, 14, '#7fd4c1');
txt(scene, VIEW_W / 2, VIEW_H * 0.735, m.daily.bestScore > 0 ? `本地最佳 ${m.daily.bestScore}` : '固定种子 · 天下同题', 11, '#8a8498');
skin.textButton(L, VIEW_W / 2, VIEW_H * 0.62, 240, 56, '启程出巡', { variant: 'primary', size: 20 });
skin.textButton(L, VIEW_W / 2, VIEW_H * 0.72, 240, 48, `每日一巡 · ${hs.home.daily.date}`, { variant: 'jade', size: 14, sub: m.daily.bestScore > 0 ? `本地最佳 ${m.daily.bestScore}` : '固定种子 · 天下同题', subColor: '#cfeee0' });
// 图鉴/成就入口。
keep(scene, scene.add.rectangle(VIEW_W / 2 - 90, VIEW_H * 0.82, 150, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 - 90, VIEW_H * 0.82, `图鉴 ${m.collection.cards + m.collection.relics + m.collection.enemies}`, 13, '#9fc4e8');
keep(scene, scene.add.rectangle(VIEW_W / 2 + 90, VIEW_H * 0.82, 150, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2 + 90, VIEW_H * 0.82, `成就 ${m.achievedCount}/40`, 13, '#b9a3d0');
skin.textButton(L, VIEW_W / 2 - 90, VIEW_H * 0.82, 150, 44, `图鉴 ${m.collection.cards + m.collection.relics + m.collection.enemies}`, { variant: 'secondary', size: 13 });
skin.textButton(L, VIEW_W / 2 + 90, VIEW_H * 0.82, 150, 44, `成就 ${m.achievedCount}/40`, { variant: 'secondary', size: 13 });
}
function drawCollection(scene, hs) {
const L = layerFor(scene);
const m = hs.home.meta;
txt(scene, VIEW_W / 2, 40, '图鉴 · 山海志', 22, '#9fc4e8');
txt(scene, VIEW_W / 2, VIEW_H * 0.2, `符箓 ${m.collection.cards} / 154`, 16, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.3, `遗物 ${m.collection.relics} / 40`, 16, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.4, `异兽 ${m.collection.enemies} / 8`, 16, '#e8e2d0');
txt(scene, VIEW_W / 2, VIEW_H * 0.55, '收录随巡途自动增补', 12, '#8a8498');
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.92, 160, 46, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2, VIEW_H * 0.92, '返回', 15, '#c9c3b2');
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.48);
skin.plaque(L, VIEW_W / 2, 40, '图鉴 · 山海志', { w: 220, h: 46, size: 22 });
const rows = [['符箓', m.collection.cards, 154], ['遗物', m.collection.relics, 40], ['异兽', m.collection.enemies, 8]];
rows.forEach(([label, got, total], i) => {
const y = VIEW_H * 0.24 + i * 0.13 * VIEW_H;
skin.panel(L, VIEW_W / 2, y, 300, 74, { radius: 10 });
stxt(scene, VIEW_W / 2 - 120, y - 14, label, 16, INK_TEXT, 0);
stxt(scene, VIEW_W / 2 + 120, y - 14, `${got} / ${total}`, 16, INK_TEXT, 1);
skin.bar(L, VIEW_W / 2, y + 16, 260, 12, got / total, SHILV);
});
stxt(scene, VIEW_W / 2, VIEW_H * 0.68, '收录随巡途自动增补', 12, '#e8dcc0');
skin.textButton(L, VIEW_W / 2, VIEW_H * 0.92, 160, 46, '返回', { variant: 'jade', size: 15 });
}
function drawAchievements(scene, hs) {
const L = layerFor(scene);
const m = hs.home.meta;
txt(scene, VIEW_W / 2, 40, `成就 · ${m.achievedCount} / 40`, 22, '#b9a3d0');
// 达成列表(前 12 条滚动位;M4 再做完整滚动容器)。
skin.bgCover(L, BG_SCENE_B, VIEW_W, VIEW_H, 0.48);
skin.plaque(L, VIEW_W / 2, 40, `成就 · ${m.achievedCount} / 40`, { w: 240, h: 46, size: 22, color: hx(0x7a4fa0) });
const byId = Object.fromEntries(ACHIEVEMENTS.map((a) => [a.id, a]));
const got = Object.keys(m.achievements).slice(0, 12);
if (got.length === 0) txt(scene, VIEW_W / 2, VIEW_H * 0.4, '尚无成就 · 出巡自见分晓', 14, '#8a8498');
got.forEach((id, i) => {
const a = byId[id];
txt(scene, VIEW_W / 2, VIEW_H * 0.18 + i * 28, `✓ ${a ? a.name : id}`, 13, '#9fe08a');
});
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.92, 160, 46, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, VIEW_W / 2, VIEW_H * 0.92, '返回', 15, '#c9c3b2');
if (got.length === 0) {
skin.panel(L, VIEW_W / 2, VIEW_H * 0.4, 300, 80, { radius: 12 });
stxt(scene, VIEW_W / 2, VIEW_H * 0.4, '尚无成就 · 出巡自见分晓', 14, INK_TEXT);
} else {
got.forEach((id, i) => {
const a = byId[id];
const y = VIEW_H * 0.17 + i * 30;
skin.panel(L, VIEW_W / 2, y, 320, 26, { radius: 6, alpha: 0.96 });
stxt(scene, VIEW_W / 2, y, `✓ ${a ? a.name : id}`, 13, hx(0x2f7a4a));
});
}
skin.textButton(L, VIEW_W / 2, VIEW_H * 0.92, 160, 46, '返回', { variant: 'jade', size: 15 });
}
// ── 终局视图 ──────────────────────────────────────────────────────────────────
function drawTerminal(scene, s) {
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.78));
const L = layerFor(scene);
const bgKey = s.phase === 'win' ? 'bg-result-win' : s.phase === 'lose' ? 'bg-result-lose' : BG_SCENE_A;
skin.bgCover(L, bgKey, VIEW_W, VIEW_H, 0.55);
const title = s.phase === 'win' ? '巡异功成' : s.phase === 'lose' ? '巡异失利' : '本局无效';
const color = s.phase === 'win' ? '#9fe08a' : s.phase === 'lose' ? '#ff6b5e' : '#f0c674';
txt(scene, VIEW_W / 2, VIEW_H * 0.36, title, 38, color);
if (s.phase === 'win') txt(scene, VIEW_W / 2, VIEW_H * 0.43, `三幕镇服 · 异兽尽收山海图`, 15, '#c9c3b2');
txt(scene, VIEW_W / 2, VIEW_H * 0.50,
`战 ${s.stats.battlesWon} · 精英 ${s.stats.elitesWon} · 王 ${s.stats.bossesWon} · 得卡 ${s.stats.cardsAdded} · 金 ${s.gold}`,
14, '#c9c3b2');
txt(scene, VIEW_W / 2, VIEW_H * 0.58, '点按任意处 · 再启一局', 15, '#e8e2d0');
}
// ── 通用卡面 ──────────────────────────────────────────────────────────────────
function drawCardFront(scene, cx, cy, card, opt) {
const cont = keep(scene, scene.add.container(cx, cy));
const g = scene.add.graphics();
g.fillStyle(opt.affordable ? 0x241f33 : 0x1a1724, 1);
g.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 10);
g.lineStyle(2, opt.selected ? 0xf0c674 : ELEMENT_COLORS[card.element], 1);
g.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 10);
g.fillStyle(ELEMENT_COLORS[card.element], 1);
g.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, 20, { tl: 10, tr: 10, bl: 0, br: 0 });
cont.add(g);
cont.add(scene.add.text(0, -CARD_H / 2 + 10, `${card.element} · ${card.name}`, { fontSize: '12px', color: '#141420', fontFamily: 'sans-serif' }).setOrigin(0.5));
cont.add(scene.add.circle(-CARD_W / 2 + 4, -CARD_H / 2 + 4, 12, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
cont.add(scene.add.text(-CARD_W / 2 + 4, -CARD_H / 2 + 4, String(card.cost), { fontSize: '13px', color: '#fff', fontFamily: 'sans-serif' }).setOrigin(0.5));
// 插画位(M4 S17):图集帧就绪→真卡插画;未就绪→程序空框降级(书 §9.3 首屏未就绪占位路径)。
const artKey = frames.atlasOf(card.id);
if (artKey) {
cont.add(scene.add.image(0, -CARD_H / 2 + 52, artKey, card.id).setDisplaySize(CARD_W - 12, 64));
} else {
const ph = scene.add.graphics();
ph.lineStyle(1, 0x4a4460, 1); ph.strokeRect(-CARD_W / 2 + 10, -CARD_H / 2 + 28, CARD_W - 20, 52);
cont.add(ph);
}
const text = card.text || '';
cont.add(scene.add.text(0, CARD_H / 2 - 34, text, { fontSize: '10px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: CARD_W - 14 }, align: 'center' }).setOrigin(0.5));
cont.setScale(opt.scale || 1);
return cont;
}
// ── 事件演出(M0 语汇迁入 + run 级新事件)────────────────────────────────────────
function intentLabel(it) {
if (it.kind === 'attack') return `意图:${it.name} · 攻 ${it.n}${(it.times || 1) > 1 ? `×${it.times}` : ''}`;
if (it.kind === 'block') return `意图:${it.name} · 甲 ${it.n}`;
if (it.kind === 'attackBlock') return `意图:${it.name} · 甲 ${it.block} 攻 ${it.n}`;
return `意图:${it.name} · 力 +${it.strength}`;
const color = s.phase === 'win' ? '#9fe08a' : s.phase === 'lose' ? '#ff6b5e' : hx(GOLD);
const tt = keep(scene, scene.add.text(VIEW_W / 2, VIEW_H * 0.34, title, { fontSize: '38px', color, fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(12));
tt.setStroke('#1a1610', 6);
scene.tweens.add({ targets: tt, scale: { from: 0.7, to: 1 }, alpha: { from: 0, to: 1 }, duration: 500, ease: 'Back.Out' });
if (s.phase === 'win') stxt(scene, VIEW_W / 2, VIEW_H * 0.43, '三幕镇服 · 异兽尽收山海图', 15, '#e8dcc0');
skin.panel(L, VIEW_W / 2, VIEW_H * 0.52, 330, 56, { radius: 12, variant: 'dark' });
stxt(scene, VIEW_W / 2, VIEW_H * 0.52,
`战 ${s.stats.battlesWon} · 精 ${s.stats.elitesWon} · 王 ${s.stats.bossesWon} · 卡 ${s.stats.cardsAdded} · 金 ${s.gold}`,
13, '#f0e6cd');
stxt(scene, VIEW_W / 2, VIEW_H * 0.6, '点按任意处 · 再启一局', 15, hx(GOLD));
}
// ── 事件演出(M0 语汇迁入 + run 级新事件;敌意图以 drawIntent/intentBadge 出徽,不再走纯文字标签)──
function playFx(scene, e) {
// 音画同帧(§7.2):同一事件在此同步出音+画。链环走八级音阶递升(chainStep),余按 sfxForEvent 映射。
let hadAudio = false;
@ -619,10 +647,13 @@ export function createRunScene(api) {
bumpFx(hadAudio);
const eCy = VIEW_H * ZONES.enemyZone / 2 + 14;
const heroY = VIEW_H * (ZONES.enemyZone + ZONES.midZone / 2);
if (e.t === 'damage') {
const hx = VIEW_W / 2 + (e.seg - 1) * 22 - 10;
spawnParticles(scene, hx, eCy - 20, 0xff6b5e, Math.min(10, 4 + Math.floor(e.dealt / 4))); // 受击颗粒(伤越高越密)
floatText(scene, hx, eCy - 40, `-${e.dealt}${e.mult > 1 ? ` ×${e.mult.toFixed(2)}` : ''}`, '#ff6b5e', 18 + Math.min(10, e.dealt / 3));
if (e.t === 'cardPlayed') {
// 出牌飞行:元素色灵符自手牌区飞向敌身(§7.2 卡牌飞行缓动)。
flyToken(scene, VIEW_W / 2, handTop() + 40, ELEMENT_COLORS[e.element] || GOLD);
} else if (e.t === 'damage') {
const hx2 = VIEW_W / 2 + (e.seg - 1) * 22 - 10;
spawnParticles(scene, hx2, eCy - 20, 0xff6b5e, Math.min(10, 4 + Math.floor(e.dealt / 4))); // 受击颗粒(伤越高越密)
floatText(scene, hx2, eCy - 40, `-${e.dealt}${e.mult > 1 ? ` ×${e.mult.toFixed(2)}` : ''}`, '#ff6b5e', 18 + Math.min(10, e.dealt / 3));
} else if (e.t === 'blockGain') {
floatText(scene, 90, heroY, `甲 +${e.n}`, '#7fb2e5', 15);
} else if (e.t === 'strengthGain' && e.target === 'hero') {
@ -633,7 +664,7 @@ export function createRunScene(api) {
const ei = ELEMENT_CYCLE.indexOf(e.element);
const sx = VIEW_W / 2 - 70 + (ei >= 0 ? ei : 0) * 35;
spawnParticles(scene, sx, my + 14, 0xffe08a, 8);
const ring = scene.add.circle(sx, my + 14, 12).setStrokeStyle(3, 0xffe08a).setDepth(45);
const ring = scene.add.circle(sx, my + 14, 13).setStrokeStyle(3, 0xffe08a).setDepth(45);
scene.tweens.add({ targets: ring, scale: { from: 1, to: 2.3 }, alpha: { from: 1, to: 0 }, duration: 380, onComplete: () => ring.destroy() });
floatText(scene, VIEW_W / 2, heroY + 6, `灵脉 ${e.links} 环 ×${(1 + e.links * 0.25).toFixed(2)}`, '#9fe08a', 15);
} else if (e.t === 'chainBreak') {
@ -641,10 +672,18 @@ export function createRunScene(api) {
} else if (e.t === 'chainGrace') {
floatText(scene, VIEW_W / 2, heroY + 6, '归墟镜 · 脉未断', '#b9a3d0', 13);
} else if (e.t === 'zhoutian') {
const flash = scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0xf0c674, 0.28).setDepth(50);
scene.tweens.add({ targets: flash, alpha: 0, duration: 320, onComplete: () => flash.destroy() });
const t = scene.add.text(VIEW_W / 2, VIEW_H * 0.44, '周 天', { fontSize: '46px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(51);
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.25 }, alpha: { from: 1, to: 0 }, duration: 700, onComplete: () => t.destroy() });
// 周天:金色满屏闪 + 放射金环 + 大字爆(§7.2 强化)。
const flash = scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, GOLD, 0.32).setDepth(50);
scene.tweens.add({ targets: flash, alpha: 0, duration: 360, onComplete: () => flash.destroy() });
const my = midY();
for (let k = 0; k < 3; k++) {
const ring = scene.add.circle(VIEW_W / 2, my + 14, 20).setStrokeStyle(4, GOLD).setDepth(51);
scene.tweens.add({ targets: ring, scale: { from: 0.4, to: 4 + k }, alpha: { from: 0.9, to: 0 }, duration: 620 + k * 120, ease: 'Cubic.Out', onComplete: () => ring.destroy() });
}
spawnParticles(scene, VIEW_W / 2, my + 14, GOLD, PRES.particleCap);
const t = scene.add.text(VIEW_W / 2, VIEW_H * 0.44, '周 天', { fontSize: '48px', color: hx(GOLD), fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(52);
t.setStroke('#7a3410', 6);
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.3 }, alpha: { from: 1, to: 0 }, duration: 800, onComplete: () => t.destroy() });
} else if (e.t === 'enemyAction' && (e.kind === 'attack' || e.kind === 'attackBlock')) {
scene.cameras.main.shake(90, 0.004);
floatText(scene, VIEW_W / 2, heroY - 8, `${e.name} -${e.dealt}`, '#ff6b5e', 17);
@ -655,22 +694,23 @@ export function createRunScene(api) {
} else if (e.t === 'enemyPhase') {
floatText(scene, VIEW_W / 2, eCy, '相位转换!', '#e2543e', 22);
} else if (e.t === 'shuffle') {
floatText(scene, VIEW_W - 60, handTop() + 40, '洗牌', '#8a8498', 12);
floatText(scene, VIEW_W - 60, handTop() + 40, '洗牌', '#e8dcc0', 12);
} else if (e.t === 'turnStart') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.5, `第 ${e.turn} 回合`, '#c9c3b2', 16);
floatText(scene, VIEW_W / 2, VIEW_H * 0.5, `第 ${e.turn} 回合`, '#e8dcc0', 16);
} else if (e.t === 'goldGain') {
floatText(scene, 70, 30, `金 +${e.n}`, '#f0c674', 14);
floatText(scene, 92, 16, `金 +${e.n}`, hx(GOLD), 14);
} else if (e.t === 'heal') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.5, `调息 +${e.n}`, '#9fe08a', 18);
} else if (e.t === 'relicGained') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.34, `得遗物 · ${e.name}`, '#b9a3d0', 20);
floatText(scene, VIEW_W / 2, VIEW_H * 0.34, `得遗物 · ${e.name}`, '#c8a6e0', 20);
} else if (e.t === 'cardAdded') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.34, '收入卡组', '#9fc4e8', 15);
} else if (e.t === 'actStart') {
const t = scene.add.text(VIEW_W / 2, VIEW_H * 0.4, `第 ${e.act} 幕`, { fontSize: '40px', color: '#e8e2d0', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(51);
const t = scene.add.text(VIEW_W / 2, VIEW_H * 0.4, `第 ${e.act} 幕`, { fontSize: '40px', color: '#f0e6cd', fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(52);
t.setStroke('#1a1610', 5);
scene.tweens.add({ targets: t, alpha: { from: 1, to: 0 }, duration: 1100, onComplete: () => t.destroy() });
} else if (e.t === 'battleStart') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, `遭遇 · ${e.enemyName}`, '#e8e2d0', 20);
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, `遭遇 · ${e.enemyName}`, '#f0e6cd', 20);
} else if (e.t === 'statusApplied') {
const label = { vulnerable: '易伤', weak: '虚弱', burn: '灼' }[e.status] || e.status;
floatText(scene, VIEW_W / 2, eCy - 20, `${label} +${e.n}`, '#e2a13e', 15);
@ -685,24 +725,25 @@ export function createRunScene(api) {
} else if (e.t === 'exhaust') {
floatText(scene, VIEW_W - 60, handTop() + 60, '符箓燃尽', '#8a8498', 12);
} else if (e.t === 'shopBuy') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.3, `易得 · ${e.price} 金`, '#f0c674', 15);
floatText(scene, VIEW_W / 2, VIEW_H * 0.3, `易得 · ${e.price} 金`, hx(GOLD), 15);
} else if (e.t === 'cardRemoved') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.3, e.price > 0 ? `销卡 · ${e.price} 金` : '销卡 · 免银', '#d9698a', 15);
} else if (e.t === 'shopEnter') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '入市', '#b9a3d0', 18);
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '入市', '#c8a6e0', 18);
} else if (e.t === 'relicRewardOffered') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '异宝呈前', '#b9a3d0', 18);
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '异宝呈前', '#c8a6e0', 18);
} else if (e.t === 'eventStart') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, `奇遇 · ${e.name}`, '#7fd4c1', 18);
} else if (e.t === 'hpMaxChange') {
floatText(scene, 90, VIEW_H * 0.5, `气血上限 ${e.n > 0 ? '+' : ''}${e.n}`, '#9fe08a', 15);
} else if (e.t === 'goldSpend') {
floatText(scene, 70, 30, `金 -${e.n}`, '#c9a35a', 14);
floatText(scene, 92, 16, `金 -${e.n}`, '#c9a35a', 14);
}
}
function floatText(scene, x, y, str, color, size) {
const t = scene.add.text(x, y, str, { fontSize: `${Math.round(size)}px`, color, fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(40);
const t = scene.add.text(x, y, str, { fontSize: `${Math.round(size)}px`, color, fontFamily: 'sans-serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(40);
t.setStroke('#1a1610', 3);
scene.tweens.add({ targets: t, y: y - 34, alpha: { from: 1, to: 0 }, duration: 620, ease: 'Cubic.Out', onComplete: () => t.destroy() });
}
}

View File

@ -39,6 +39,30 @@ export const UI_FRAMES = {
bg: { hud: 'ui-bg-hud', panel: 'ui-bg-panel' },
};
/**
* M4 打磨:交互 UI chrome 松散透明件(mmx 生图 → 抠图,不进 13 图集,避免扰动卡/敌/遗物分包与 P7 帧数对账)。
* 能量宝珠/节点铜章抠透明(贴任意底);题头匾额/卷轴横条为不透明装饰条。缺件(headless/未就绪)时
* skin.js 同名基元退回程序绘制。路径相对 index.html(游戏根),与图集同装载通道。
*/
export const UI_CHROME = [
{ key: 'ui-orb-energy', url: './assets/ui/orb-energy.webp' },
{ key: 'ui-node-medallion', url: './assets/ui/node-medallion.webp' },
{ key: 'ui-plaque-title', url: './assets/ui/plaque-title.webp' },
{ key: 'ui-rail-scroll', url: './assets/ui/rail-scroll.webp' },
];
/**
* preload 阶段加载松散 UI chrome 图(real 通道;mock/headless 无 scene.load.image → no-op)。
* @param {any} scene Phaser.Scene(this)
*/
export function preloadUiChrome(scene) {
if (!scene || !scene.load || typeof scene.load.image !== 'function') return; // mock/headless:无 loader
for (const c of UI_CHROME) {
try { scene.load.image(c.key, c.url); }
catch (e) { /* 单件加载登记失败不阻其余(缺件降级程序绘制) */ }
}
}
/**
* preload 阶段加载全部图集(real 通道;scene.load 存在才做,mock/headless no-op)。
* @param {any} scene Phaser.Scene(this)

View File

@ -0,0 +1,411 @@
/**
* src/ui/skin.js —— 《山海巡异录》青绿卷轴国风皮肤基元(M4 打磨;渲染基元单源)
* owner:北极星顶级线③(M4 表现层精致化)
*
* 【这份是什么】把「一块面板/一颗宝珠/一枚五行珠/一条血槽/一方匾额/一枚节点铜章/一张卡面」怎么画成
* 矿彩国风(锚 r1:石青石绿底、描金勾线、册页卷轴质感、朱砂点缀)收敛到一处。run-scene 只管布局与
* 事件分发,不在场景里散落 fillRect 魔法数——去「方方正正」的观感升级全靠这层。
*
* 【真素材优先,程序兜底】有生成图(卡框 ui-frame-*、面板底 ui-bg-panel、能量宝珠 ui-orb-energy、
* 节点铜章 ui-node-medallion、题头匾额 ui-plaque-title 等)就贴真图;headless/mock/首屏未就绪时,
* 同一函数退回「描金圆角+渐变+柔光」的程序绘制(书 §9.3 占位路径),观感降级但不缺件、不抛异常。
*
* 【node/mock 安全】所有取纹理走 hasTex/hasFrame 守卫(mock 场景无 scene.textures);全部 add.* 由调用方
* 经 L.keep 纳入重建层随屏销毁。scene-smoke 以记录型 mock 真跑本层每个绘制路径,异常零容忍。
*
* 【坐标契约】本层只负责「在给定中心画得好看」,命中靶仍由 layout.js 单源——绘制锚点必须与 layout 一致,
* 不得因美化平移可点区域(出牌/结束/节点/奖励的命中框不动)。
*/
'use strict';
/** 锚 r1 矿彩色板(anchor-pack.json palette 单源转 0xRRGGBB;渲染层只认这份,不另立色表)。 */
export const INK = 0x14140f; // 墨底(暗岩再压深,做背景压暗)
export const INK2 = 0x1e2420; // 暗岩
export const SHIQING = 0x2b5f75; // 石青
export const SHILV = 0x3f7d5a; // 石绿
export const ZHESHI = 0x9c5b2f; // 赭石
export const ZHUSHA = 0xc3402b; // 朱砂
export const CIHUANG = 0xd9a441; // 雌黄
export const GOLD = 0xe8c56a; // 描金高光
export const GOLD_DK = 0xa9822f; // 描金暗部(双描边内线)
export const CREAM = 0xf3ead2; // 浅米(册页纸面)
export const CREAM_DK = 0xe3d5b0; // 米色暗部(纸面渐变下沿)
export const INK_TEXT = '#241a10'; // 纸面上的墨字
export const GOLD_TEXT = '#f0d78a'; // 暗底上的描金字
export const JADE_TEXT = '#bfe3d0'; // 青玉字
/** 十六进制色字符串(text color 用)。 */
export function hx(n) { return `#${(n & 0xffffff).toString(16).padStart(6, '0')}`; }
/** 颜色线性混合(0=a,1=b);做渐变/亮度分层。 */
export function mix(a, b, t) {
const ar = (a >> 16) & 0xff, ag = (a >> 8) & 0xff, ab = a & 0xff;
const br = (b >> 16) & 0xff, bg = (b >> 8) & 0xff, bb = b & 0xff;
const r = Math.round(ar + (br - ar) * t), g = Math.round(ag + (bg - ag) * t), bl = Math.round(ab + (bb - ab) * t);
return (r << 16) | (g << 8) | bl;
}
/** 提亮/压暗(t>0 提亮向白,t<0 压暗向黑)。 */
export function shade(c, t) { return t >= 0 ? mix(c, 0xffffff, t) : mix(c, 0x000000, -t); }
/**
* 建绘制层上下文(run-scene 每次 rebuild 传一份;集中纹理可用性守卫,避免 mock 无 textures 崩)。
* @param {any} scene Phaser.Scene
* @param {(o:any)=>any} keep run-scene 的显示对象登记(随屏销毁)
* @param {{atlasOf:(id:string)=>string|null, has:(id:string)=>boolean}} frames 图集帧索引
*/
export function makeLayer(scene, keep, frames) {
const hasTex = (key) => {
try { return !!(scene && scene.textures && typeof scene.textures.exists === 'function' && scene.textures.exists(key)); }
catch (e) { return false; }
};
return {
scene, keep, frames,
hasTex,
hasFrame: (id) => { try { return !!(frames && frames.has && frames.has(id)); } catch (e) { return false; } },
frameKey: (id) => { try { return (frames && frames.atlasOf && frames.atlasOf(id)) || null; } catch (e) { return null; } },
};
}
/** 便捷:登记并返回(所有基元内部走它)。 */
function K(L, o) { return L.keep(o); }
/**
* 满幅背景图(cover 铺满 390×844;可叠压暗蒙版保证前景文字可读)。
* 有 bg 图(bg-scene 与 bg-result 在 bg 松散纹理里)就贴,缺则退回竖向石青→墨底渐变。
* @param {number} scrim 压暗蒙版透明度(0=不压)
*/
export function bgCover(L, key, W, H, scrim = 0.32, tint = null) {
const { scene } = L;
if (L.hasTex(key)) {
const img = K(L, scene.add.image(W / 2, H / 2, key).setDepth(-20));
// cover:按最大比放缩铺满,居中裁切外溢
const iw = img.width || W, ih = img.height || H;
const s = Math.max(W / iw, H / ih);
img.setScale(s);
if (tint != null) img.setTint(tint);
if (scrim > 0) K(L, scene.add.rectangle(W / 2, H / 2, W, H, INK, scrim).setDepth(-19));
return img;
}
// 程序兜底:竖向矿彩渐变
const g = K(L, scene.add.graphics().setDepth(-20));
g.fillGradientStyle(shade(SHIQING, -0.3), shade(SHIQING, -0.3), INK, INK, 1);
g.fillRect(0, 0, W, H);
if (scrim > 0) { g.fillStyle(INK, scrim); g.fillRect(0, 0, W, H); }
return g;
}
/** 全屏压暗蒙版(弹窗屏用;depth 低于面板)。 */
export function scrim(L, W, H, alpha = 0.55) {
return K(L, L.scene.add.rectangle(W / 2, H / 2, W, H, 0x0a0a12, alpha));
}
/**
* 描金云角(面板/卡框四角的收角小云纹;程序勾一笔金弧 + 点,替「硬直角」的方框感)。
*/
function goldCorner(L, x, y, dx, dy, s = 12) {
const g = K(L, L.scene.add.graphics());
g.lineStyle(1.6, GOLD, 0.9);
g.beginPath();
g.moveTo(x + dx * s, y); g.lineTo(x, y); g.lineTo(x, y + dy * s);
g.strokePath();
g.fillStyle(GOLD, 0.9); g.fillCircle(x, y, 1.8);
return g;
}
/**
* 卷轴册页面板(弹窗/信息屏底):有 ui-bg-panel 帧就贴册页纸面(cover 到区域),否则程序绘
* 「浅米圆角 + 双描金边 + 四角云纹」。中心是浅米纸面,承墨字。
* @param {'panel'|'dark'} variant panel=浅米册页;dark=暗岩矿底(HUD/深色屏)
*/
export function panel(L, x, y, w, h, opt = {}) {
const { scene } = L;
const variant = opt.variant || 'panel';
const r = opt.radius != null ? opt.radius : 14;
const depth = opt.depth != null ? opt.depth : 6;
const frameId = variant === 'dark' ? 'ui-bg-hud' : 'ui-bg-panel';
const fk = L.frameKey(frameId);
if (fk && opt.raster !== false) {
// 真册页底纹:用几何裁切成圆角区域(container+mask 过重,这里贴图 + 描金边框勾勒即可)
const img = K(L, scene.add.image(x, y, fk, frameId).setDisplaySize(w, h));
if (opt.alpha != null) img.setAlpha(opt.alpha);
const g = K(L, scene.add.graphics());
g.lineStyle(2.5, GOLD, 0.95); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, r);
g.lineStyle(1, GOLD_DK, 0.8); g.strokeRoundedRect(x - w / 2 + 3, y - h / 2 + 3, w - 6, h - 6, r - 2);
return img;
}
// 程序兜底
const g = K(L, scene.add.graphics());
if (variant === 'dark') {
g.fillGradientStyle(shade(INK2, 0.12), shade(INK2, 0.12), INK, INK, 1);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, r);
g.lineStyle(2, GOLD_DK, 0.9); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, r);
} else {
g.fillGradientStyle(CREAM, CREAM, CREAM_DK, CREAM_DK, 1);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, r);
g.lineStyle(2.5, GOLD, 0.95); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, r);
g.lineStyle(1, GOLD_DK, 0.7); g.strokeRoundedRect(x - w / 2 + 3, y - h / 2 + 3, w - 6, h - 6, r - 2);
}
// 四角云纹(浅米面板才加,暗面板保持沉稳)
if (variant !== 'dark') {
const hw = w / 2 - 6, hh = h / 2 - 6;
goldCorner(L, x - hw, y - hh, 1, 1); goldCorner(L, x + hw, y - hh, -1, 1);
goldCorner(L, x - hw, y + hh, 1, -1); goldCorner(L, x + hw, y + hh, -1, -1);
}
return g;
}
/**
* 题头匾额(屏幕标题):有 ui-plaque-title 帧就贴匾额牌 + 居中墨字;否则程序绘描金横牌。
* @returns 标题文本对象
*/
export function plaque(L, cx, y, text, opt = {}) {
const { scene } = L;
const w = opt.w || 240, h = opt.h || 52;
const size = opt.size || 20;
const color = opt.color || INK_TEXT;
if (L.hasTex('ui-plaque-title')) {
K(L, scene.add.image(cx, y, 'ui-plaque-title').setDisplaySize(w, h));
} else {
const g = K(L, scene.add.graphics());
g.fillGradientStyle(SHIQING, SHIQING, shade(SHIQING, -0.25), shade(SHIQING, -0.25), 1);
g.fillRoundedRect(cx - w / 2, y - h / 2, w, h, 10);
g.lineStyle(2.5, GOLD, 0.95); g.strokeRoundedRect(cx - w / 2, y - h / 2, w, h, 10);
// 内嵌浅米牌心
g.fillStyle(CREAM, 0.92); g.fillRoundedRect(cx - w / 2 + 12, y - h / 2 + 8, w - 24, h - 16, 6);
}
return K(L, scene.add.text(cx, y, text, { fontSize: `${size}px`, color, fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
}
/**
* 顶栏卷轴横条(HUD 底):有 ui-rail-scroll 帧就贴卷轴条,否则程序绘暗岩描金窄条。
*/
export function hudBanner(L, cx, y, w, h) {
const { scene } = L;
if (L.hasTex('ui-rail-scroll')) {
return K(L, scene.add.image(cx, y, 'ui-rail-scroll').setDisplaySize(w, h));
}
const g = K(L, scene.add.graphics());
g.fillGradientStyle(shade(INK2, 0.15), shade(INK2, 0.15), INK, INK, 0.96);
g.fillRoundedRect(cx - w / 2, y - h / 2, w, h, 8);
g.lineStyle(1.5, GOLD_DK, 0.85); g.strokeRoundedRect(cx - w / 2, y - h / 2, w, h, 8);
return g;
}
/**
* 灵力能量宝珠:有 ui-orb-energy 就贴发光宝珠 + 描金环;否则程序绘「多层同心柔光珠」。
* 数值(能量 x/y)由调用方叠字——珠是常驻件,数值随态刷新。
*/
export function orb(L, x, y, r, opt = {}) {
const { scene } = L;
if (L.hasTex('ui-orb-energy')) {
const img = K(L, scene.add.image(x, y, 'ui-orb-energy').setDisplaySize(r * 2.2, r * 2.2));
if (opt.dim) img.setAlpha(0.5);
return img;
}
const g = K(L, scene.add.graphics());
// 外柔光
for (let i = 4; i >= 1; i--) { g.fillStyle(SHILV, 0.06 * i); g.fillCircle(x, y, r + i * 2); }
// 珠体分层(暗绿→亮青)
g.fillStyle(shade(SHILV, -0.35), 1); g.fillCircle(x, y, r);
g.fillStyle(SHILV, 1); g.fillCircle(x, y, r * 0.9);
g.fillStyle(mix(SHILV, CIHUANG, 0.15), 0.9); g.fillCircle(x - r * 0.18, y - r * 0.18, r * 0.55);
g.fillStyle(GOLD, 0.85); g.fillCircle(x, y, r * 0.18); // 珠心灵光
g.fillStyle(0xffffff, 0.5); g.fillCircle(x - r * 0.3, y - r * 0.32, r * 0.14); // 高光
g.lineStyle(2, GOLD, 0.95); g.strokeCircle(x, y, r); // 描金环
return g;
}
/**
* 五行链珠(链轨槽位):程序绘「描金环 + 元素色渐变珠 + 高光」;点亮态加外发光环 + 提亮,
* next(下一相生位)加一圈脉动金环(脉动 tween 由调用方加,或本函数返回对象)。
* @returns {{main:any, glow:any|null}}
*/
export function gem(L, x, y, r, colorHex, opt = {}) {
const { scene } = L;
const lit = !!opt.lit;
const g = K(L, scene.add.graphics());
if (lit) { for (let i = 3; i >= 1; i--) { g.fillStyle(colorHex, 0.09 * i); g.fillCircle(x, y, r + i * 2.2); } }
// 珠体
const body = lit ? colorHex : shade(colorHex, -0.55);
g.fillStyle(shade(body, -0.3), 1); g.fillCircle(x, y, r);
g.fillStyle(body, 1); g.fillCircle(x, y, r * 0.86);
if (lit) { g.fillStyle(shade(colorHex, 0.35), 0.9); g.fillCircle(x - r * 0.2, y - r * 0.22, r * 0.42); g.fillStyle(0xffffff, 0.5); g.fillCircle(x - r * 0.28, y - r * 0.3, r * 0.16); }
g.lineStyle(1.8, lit ? GOLD : GOLD_DK, lit ? 0.95 : 0.6); g.strokeCircle(x, y, r);
let glow = null;
if (opt.next) { glow = K(L, scene.add.circle(x, y, r + 4).setStrokeStyle(2, GOLD)); }
return { main: g, glow };
}
/**
* 描金框血/进度槽:底槽(暗)+ 前景填充(渐变)+ 描金边;可选中点刻度。
*/
export function bar(L, x, y, w, h, frac, colorHex, opt = {}) {
const { scene } = L;
const f = Math.max(0, Math.min(1, frac));
const g = K(L, scene.add.graphics());
g.fillStyle(shade(INK2, -0.2), 0.95); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, h / 2);
if (f > 0) {
g.fillGradientStyle(shade(colorHex, 0.25), shade(colorHex, 0.25), shade(colorHex, -0.2), shade(colorHex, -0.2), 1);
g.fillRoundedRect(x - w / 2 + 1, y - h / 2 + 1, Math.max(h - 2, (w - 2) * f), h - 2, (h - 2) / 2);
}
g.lineStyle(1.5, GOLD, 0.85); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, h / 2);
return g;
}
/**
* 国风按钮(去「描边方框」):圆角牌面竖向渐变 + 双描金边 + 四角小钉,三种漆色变体。
* primary=朱漆(主行动)/ secondary=青玉(次要)/ danger=暗朱(销卡等)/ ghost=素淡(禁用感)。
* @returns 供调用方叠标签文本(本函数只画牌面)
*/
export function button(L, x, y, w, h, opt = {}) {
const { scene } = L;
const enabled = opt.enabled !== false;
const variant = opt.variant || 'secondary';
const base = variant === 'primary' ? ZHUSHA : variant === 'danger' ? shade(ZHUSHA, -0.25)
: variant === 'jade' ? SHILV : variant === 'ghost' ? INK2 : SHIQING;
const top = enabled ? shade(base, 0.22) : shade(base, -0.1);
const bot = enabled ? shade(base, -0.28) : shade(base, -0.4);
const g = K(L, scene.add.graphics());
g.fillGradientStyle(top, top, bot, bot, enabled ? 1 : 0.75);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 10);
g.lineStyle(2, enabled ? GOLD : GOLD_DK, enabled ? 0.95 : 0.5); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 10);
g.lineStyle(1, shade(base, 0.35), enabled ? 0.5 : 0.25); g.strokeRoundedRect(x - w / 2 + 3, y - h / 2 + 3, w - 6, h - 6, 7);
// 四角描金小钉
if (enabled && w > 60) {
const insx = w / 2 - 8, insy = h / 2 - 8;
g.fillStyle(GOLD, 0.9);
for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) g.fillCircle(x + sx * insx, y + sy * insy, 1.6);
}
return g;
}
/** 按钮 + 居中标签一体(最常用);label 可两行(主/副)。 */
export function textButton(L, x, y, w, h, label, opt = {}) {
button(L, x, y, w, h, opt);
const enabled = opt.enabled !== false;
const color = opt.color || (enabled ? GOLD_TEXT : '#7a7466');
const size = opt.size || 15;
if (opt.sub) {
K(L, L.scene.add.text(x, y - 7, label, { fontSize: `${size}px`, color, fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
K(L, L.scene.add.text(x, y + 11, opt.sub, { fontSize: '11px', color: opt.subColor || GOLD_TEXT, fontFamily: 'sans-serif' }).setOrigin(0.5));
} else {
K(L, L.scene.add.text(x, y, label, { fontSize: `${size}px`, color, fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
}
}
/**
* 地图节点铜章:有 ui-node-medallion 就贴描金空心环 + 中心色盘 + 字;否则程序绘双环 + 字。
* state:'here'(所在)/'selectable'(可选,外圈脉动由调用方加)/'visited'/'locked'。
* @returns {{ring:any}} 供调用方对可选态加脉动
*/
export function nodeMedallion(L, x, y, r, glyph, colorHex, opt = {}) {
const { scene } = L;
const state = opt.state || 'locked';
const dim = state === 'visited';
// 中心色盘(节点类型色)
const disc = K(L, scene.add.graphics());
disc.fillStyle(dim ? shade(colorHex, -0.55) : shade(colorHex, -0.15), 1); disc.fillCircle(x, y, r * 0.72);
disc.fillStyle(dim ? shade(colorHex, -0.4) : colorHex, 1); disc.fillCircle(x, y, r * 0.62);
if (!dim) { disc.fillStyle(shade(colorHex, 0.3), 0.8); disc.fillCircle(x - r * 0.2, y - r * 0.22, r * 0.28); }
let ring;
if (L.hasTex('ui-node-medallion')) {
ring = K(L, scene.add.image(x, y, 'ui-node-medallion').setDisplaySize(r * 2.15, r * 2.15));
if (dim) ring.setAlpha(0.5);
} else {
const g = K(L, scene.add.graphics());
g.lineStyle(3, dim ? GOLD_DK : GOLD, dim ? 0.5 : 0.95); g.strokeCircle(x, y, r);
g.lineStyle(1.2, GOLD_DK, 0.7); g.strokeCircle(x, y, r * 0.82);
ring = g;
}
K(L, scene.add.text(x, y, glyph, { fontSize: `${Math.round(r * 0.9)}px`, color: dim ? '#8a8272' : INK_TEXT, fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
return { ring, disc };
}
/**
* 敌意图小徽(去纯文字):圆角小牌 + 类型色 + 图示字 + 数值。
*/
export function intentBadge(L, x, y, kind, glyph, value) {
const { scene } = L;
const col = kind === 'attack' ? ZHUSHA : kind === 'block' ? SHIQING : kind === 'attackBlock' ? ZHESHI : CIHUANG;
const w = 92, h = 30;
const g = K(L, scene.add.graphics());
g.fillGradientStyle(shade(col, 0.18), shade(col, 0.18), shade(col, -0.25), shade(col, -0.25), 0.96);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
g.lineStyle(1.6, GOLD, 0.9); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
K(L, scene.add.text(x, y, `${glyph} ${value}`, { fontSize: '15px', color: GOLD_TEXT, fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
return g;
}
/**
* HUD 数值图钉(顶栏:小色点图标 + 值),替裸文字,给数值层次。
*/
export function statChip(L, x, y, glyph, value, colorHex, opt = {}) {
const { scene } = L;
const g = K(L, scene.add.graphics());
g.fillStyle(shade(colorHex, -0.15), 1); g.fillCircle(x, y, 9);
g.lineStyle(1.4, GOLD, 0.85); g.strokeCircle(x, y, 9);
K(L, scene.add.text(x, y, glyph, { fontSize: '11px', color: '#f7efdc', fontFamily: 'serif' }).setOrigin(0.5));
return K(L, scene.add.text(x + 13, y, `${value}`, { fontSize: '13px', color: opt.color || GOLD_TEXT, fontFamily: 'sans-serif', fontStyle: 'bold' }).setOrigin(0, 0.5));
}
/**
* 卡面(核心去方框件):册页卡框(ui-frame-<rarity> 有就贴,否则程序描金圆角)+ 元素色顶带 +
* 费珠 + 卡名 + 插画位(卡插画帧就绪贴真图)+ 文案 + 选中/可用态。返回 container 供演出 tween。
* @param {{selected:boolean,affordable:boolean,scale:number}} opt
* @param {number} CARD_W
* @param {number} CARD_H
* @param {Record<string,number>} elemColors ELEMENT_COLORS
*/
export function cardFront(L, cx, cy, card, opt, CARD_W, CARD_H, elemColors) {
const { scene } = L;
const cont = K(L, scene.add.container(cx, cy));
const hw = CARD_W / 2, hh = CARD_H / 2;
const ecol = elemColors[card.element] || SHIQING;
const rarity = card.rarity === '稀有' ? 'epic' : card.rarity === '罕见' ? 'rare' : 'common';
const frameId = `ui-frame-${rarity}`;
const fk = L.frameKey(frameId);
if (fk) {
// 真册页卡框(浅米中心承插画+文字)
const bg = scene.add.image(0, 0, fk, frameId).setDisplaySize(CARD_W, CARD_H);
if (!opt.affordable) bg.setTint(0x9a94a8);
cont.add(bg);
} else {
// 程序卡框:浅米纸面 + 元素色顶带 + 描金边
const g = scene.add.graphics();
g.fillGradientStyle(opt.affordable ? CREAM : shade(CREAM, -0.25), opt.affordable ? CREAM : shade(CREAM, -0.25), CREAM_DK, CREAM_DK, 1);
g.fillRoundedRect(-hw, -hh, CARD_W, CARD_H, 9);
g.fillStyle(ecol, 1); g.fillRoundedRect(-hw, -hh, CARD_W, 22, { tl: 9, tr: 9, bl: 0, br: 0 });
g.lineStyle(2, GOLD, 0.9); g.strokeRoundedRect(-hw, -hh, CARD_W, CARD_H, 9);
cont.add(g);
}
// 卡名(顶带上;浅米卡框顶用元素色描边小牌托字)
cont.add(scene.add.text(0, -hh + 12, card.name, { fontSize: '12px', color: fk ? INK_TEXT : '#faf4e6', fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
// 插画位
const artK = L.frameKey(card.id);
if (artK) {
cont.add(scene.add.image(0, -hh + 54, artK, card.id).setDisplaySize(CARD_W - 16, 60));
} else {
const ph = scene.add.graphics();
ph.fillStyle(shade(ecol, -0.2), 0.18); ph.fillRoundedRect(-hw + 9, -hh + 26, CARD_W - 18, 56, 5);
ph.lineStyle(1, GOLD_DK, 0.5); ph.strokeRoundedRect(-hw + 9, -hh + 26, CARD_W - 18, 56, 5);
cont.add(ph);
}
// 费珠(左上)
const cg = scene.add.graphics();
cg.fillStyle(shade(SHIQING, -0.2), 1); cg.fillCircle(-hw + 12, -hh + 12, 11);
cg.lineStyle(1.6, GOLD, 0.95); cg.strokeCircle(-hw + 12, -hh + 12, 11);
cont.add(cg);
cont.add(scene.add.text(-hw + 12, -hh + 12, String(card.cost), { fontSize: '13px', color: '#fff', fontFamily: 'serif', fontStyle: 'bold' }).setOrigin(0.5));
// 文案(下半;浅米纸面墨字)
cont.add(scene.add.text(0, hh - 30, card.text || '', { fontSize: '10px', color: fk ? INK_TEXT : '#3a2c18', fontFamily: 'sans-serif', wordWrap: { width: CARD_W - 16 }, align: 'center' }).setOrigin(0.5));
// 选中态:朱砂描金外框 + 抬亮
if (opt.selected) {
const sg = scene.add.graphics();
sg.lineStyle(3, GOLD, 1); sg.strokeRoundedRect(-hw - 2, -hh - 2, CARD_W + 4, CARD_H + 4, 11);
sg.lineStyle(1.5, ZHUSHA, 0.9); sg.strokeRoundedRect(-hw - 4, -hh - 4, CARD_W + 8, CARD_H + 8, 12);
cont.add(sg);
}
cont.setScale(opt.scale || 1);
return cont;
}