fix: 修复批量缺陷并优化性能与交互体验

修复移动端摇杆输入失效问题,修正Rex摇杆的力值使用逻辑
重构输入互斥为计数模型,新增ui:opened事件解决纪念页穿透输入问题
修复引导箭头首帧闪现问题,设置初始透明度过渡
修复hasSave误判存档问题,新增合法景点id校验集
修复底图加载失败白屏问题,新增占位底图降级逻辑
移除无用的Phaser照片预加载,节省约65MB纹理显存
优化性能:缓存景点索引与解锁集合,跳过UI锁期间的世界更新
升级纪念页支持点击照片复用拍立得弹窗放大,新增照片降级
修复HUD指针事件不生效的CSS特异性问题
同步更新事件协议文档与输入锁模型说明
This commit is contained in:
guoxichao committed 2026-10-17 20:54:23 +08:00
1 parent a404e42ca8
commit b0467054c8
11 files changed
+237 -86

No files matched your search

+14
View File
@@ -3,6 +3,20 @@
本项目所有显著变更均记录于此。 本项目所有显著变更均记录于此。
版本号:`vYYYY.MM.DD-XX`;类型标签:[Add] / [Change] / [Fix] / [Remove] / [Doc] / [Chore] 版本号:`vYYYY.MM.DD-XX`;类型标签:[Add] / [Change] / [Fix] / [Remove] / [Doc] / [Chore]
## v2026.10.17-01
18:27 · 全量代码审查后的缺陷修复与性能优化(不改变玩法与视觉基调)。
- [Fix] 移动端摇杆正方向失效:rex 的 `forceX/forceY` 是像素级位移而非 [-1,1],且正上/下/左/右必有一轴为 0;改用插件 `noKey` 判定推动状态
- [Fix] 纪念页显示期间地图输入未锁定可被穿透操作:输入互斥改为 `uiLocks` 计数模型,新增 `ui:opened` 事件由各 UI 组件自行加锁
- [Fix] 底图加载失败导致白屏:新增占位底图降级;MapScene 底图改用 `setDisplaySize` 铺满世界
- [Fix] `hasSave()` 误报有存档:改以解锁集合判定;`unlock()` 增加非法 id 校验(顺带把加载期 O(n) 过滤改为 O(1))
- [Fix] 引导箭头首帧闪现(初始 alpha 归零);HUD `pointer-events: none` 被 `#ui > *` 特异性覆盖而失效
- [Change] 性能:缓存解锁集合与景点索引,消除每帧 `getSnapshot()` 分配与 `SPOTS.find` 线性查找;UI 持锁期间直接跳过世界更新
- [Remove] 移除从未被消费的 Phaser 照片预加载与 `buildPhotoPlaceholder`(照片由 DOM `<img>` 按需加载,原实现白占约 65MB 纹理显存)
- [Change] 纪念页九宫格改用真实照片(缺失降级为景点图标)并支持点击复用拍立得弹窗放大重看
- [Doc] 同步 `docs/spec.md` 事件协议表与输入锁模型说明
## v2026.10.16-01 ## v2026.10.16-01
首次完整实现(M1–M3):可玩核心 + 完整体验 + 手账美化(全占位资源)。 首次完整实现(M1–M3):可玩核心 + 完整体验 + 手账美化(全占位资源)。
+8 -2
View File
@@ -65,13 +65,19 @@ index.html
| 事件名 | 方向 | 载荷 | 语义 | | 事件名 | 方向 | 载荷 | 语义 |
|---|---|---|---| |---|---|---|---|
| `poi:reached` | Phaser → UI | `spot` 配置对象 | 玩家进入景点触发区,请求弹窗 | | `poi:reached` | Phaser / UI → UI | `{ spot }` | 请求弹出景点拍立得弹窗(进入触发区,或纪念页点击照片) |
| `ui:closed` | UI → Phaser | 无 | 弹窗/封面关闭,恢复游戏输入 | | `ui:opened` | UI → Phaser | 无 | 弹窗/纪念页显示,获取一层输入锁 |
| `ui:closed` | UI → Phaser | `{ spotId? }` | 关闭,释放一层输入锁;带 `spotId` 时触发该景点解锁与冷却 |
| `state:changed` | game-state → 两层 | 进度快照 | 解锁变化,刷新 HUD 与地图标记 | | `state:changed` | game-state → 两层 | 进度快照 | 解锁变化,刷新 HUD 与地图标记 |
| `game:complete` | game-state → UI | 进度快照 | 全部解锁,弹出纪念页 | | `game:complete` | game-state → UI | 进度快照 | 全部解锁,弹出纪念页 |
### 3.3 输入互斥(弹窗打开时) ### 3.3 输入互斥(弹窗打开时)
**输入锁计数模型**:MapScene 维护 `uiLocks`,封面页初始持有 1 层;每个 UI 组件
(PhotoModal / CompleteOverlay)显示时 emit `ui:opened` 加锁、关闭时 emit `ui:closed`
解锁;计数归零才恢复游戏输入。这样纪念页与其上叠加的拍立得弹窗能各自持锁,
不会因先关闭其中一层就提前放行地图输入。
双保险: 双保险:
1. DOM 层全屏遮罩拦截所有触摸/点击(`pointer-events: auto`) 1. DOM 层全屏遮罩拦截所有触摸/点击(`pointer-events: auto`)
+3 -2
View File
@@ -7,8 +7,9 @@
* 核心职责: * 核心职责:
* 1. 极简发布订阅实现(on / once / off / emit); * 1. 极简发布订阅实现(on / once / off / emit);
* 2. 约定事件协议(见 docs/spec.md 3.2 节): * 2. 约定事件协议(见 docs/spec.md 3.2 节):
* - poi:reached Phaser → UI 玩家进入景点触发区,请求弹窗 * - poi:reached Phaser/UI → UI 请求弹出景点拍立得弹窗
* - ui:closed UI → Phaser 弹窗/封面关闭,恢复游戏输入 * - ui:opened UI → Phaser 弹窗/纪念页显示,获取一层输入锁
* - ui:closed UI → Phaser 弹窗/封面/纪念页关闭,释放一层输入锁
* - state:changed 存档 → 两层 解锁进度变化,刷新 HUD 与地图标记 * - state:changed 存档 → 两层 解锁进度变化,刷新 HUD 与地图标记
* - game:complete 存档 → UI 全部解锁,弹出纪念页 * - game:complete 存档 → UI 全部解锁,弹出纪念页
* *
+13 -7
View File
@@ -21,6 +21,9 @@ import { SPOTS } from './data/spots.js';
/** localStorage 存档键名。 */ /** localStorage 存档键名。 */
const STORAGE_KEY = 'xian-travel-progress'; const STORAGE_KEY = 'xian-travel-progress';
/** 合法景点 id 集合:用于过滤存档脏数据与拦截非法解锁。 */
const VALID_IDS = new Set(SPOTS.map((spot) => spot.id));
/** 内存降级存储(localStorage 不可用时使用)。 */ /** 内存降级存储(localStorage 不可用时使用)。 */
const memoryStore = { unlocked: [] }; const memoryStore = { unlocked: [] };
@@ -68,7 +71,7 @@ function ensureLoaded() {
if (saved && Array.isArray(saved.unlocked)) { if (saved && Array.isArray(saved.unlocked)) {
// 只保留 spots.js 中仍存在的 id,容错配置变更后的脏数据。 // 只保留 spots.js 中仍存在的 id,容错配置变更后的脏数据。
saved.unlocked saved.unlocked
.filter((id) => SPOTS.some((spot) => spot.id === id)) .filter((id) => VALID_IDS.has(id))
.forEach((id) => unlockedSet.add(id)); .forEach((id) => unlockedSet.add(id));
} }
} catch { } catch {
@@ -112,12 +115,12 @@ export function loadProgress() {
/** /**
* 解锁指定景点:写档并广播进度变化。 * 解锁指定景点:写档并广播进度变化。
* *
* @param {string} spotId 景点唯一 id。 * @param {string} spotId 景点唯一 id(必须是 spots.js 中存在的 id)。
* @returns {boolean} 本次调用是否真正解锁(已解锁时返回 false)。 * @returns {boolean} 本次调用是否真正解锁(非法 id 或已解锁时返回 false)。
*/ */
export function unlock(spotId) { export function unlock(spotId) {
ensureLoaded(); ensureLoaded();
if (unlockedSet.has(spotId)) { if (!VALID_IDS.has(spotId) || unlockedSet.has(spotId)) {
return false; return false;
} }
unlockedSet.add(spotId); unlockedSet.add(spotId);
@@ -165,11 +168,14 @@ export function resetProgress() {
} }
/** /**
* 是否有历史存档(决定封面页显示"继续旅行"还是"开始旅行")。 * 是否存在有效进度(决定封面页显示"继续旅行"还是"开始旅行")。
* *
* @returns {boolean} 存在未空存档返回 true。 * 以解锁集合为准:`resetProgress()` 后存储中仍留有空的 unlocked 数组,
* 若按"存储键是否存在"判断会误报有存档。
*
* @returns {boolean} 存在至少一个已解锁景点返回 true。
*/ */
export function hasSave() { export function hasSave() {
ensureLoaded(); ensureLoaded();
return persistent ? window.localStorage.getItem(STORAGE_KEY) !== null : unlockedSet.size > 0; return unlockedSet.size > 0;
} }
+3
View File
@@ -59,6 +59,9 @@ export default class GuideArrow extends Phaser.GameObjects.Container {
.setOrigin(1, 0.5); .setOrigin(1, 0.5);
this.add([this.arrow, this.label]); this.add([this.arrow, this.label]);
// 初始 alpha 为 0:首帧由 update() 按"目标是否在视野内"淡入,
// 避免目标已在视野内时箭头闪现一帧。
this.setAlpha(0);
this.setVisible(false); this.setVisible(false);
} }
+12 -2
View File
@@ -34,6 +34,10 @@ const MOVE_SPEED = 260;
* @returns {void} * @returns {void}
*/ */
function buildPlayerTexture(scene) { function buildPlayerTexture(scene) {
// 纹理已存在时直接复用(如场景重启),避免 createCanvas 返回 null 后抛错。
if (scene.textures.exists('player')) {
return;
}
const dirs = ['down', 'up', 'left', 'right']; const dirs = ['down', 'up', 'left', 'right'];
const totalFrames = dirs.length * FRAMES_PER_DIR; const totalFrames = dirs.length * FRAMES_PER_DIR;
const canvasTexture = scene.textures.createCanvas( const canvasTexture = scene.textures.createCanvas(
@@ -223,6 +227,9 @@ export default class Player extends Phaser.Physics.Arcade.Sprite {
['walk-left', 4], ['walk-left', 4],
['walk-right', 6], ['walk-right', 6],
].forEach(([key, base]) => { ].forEach(([key, base]) => {
if (scene.anims.exists(key)) {
return; // 场景重启时避免重复注册同名动画。
}
scene.anims.create({ scene.anims.create({
key, key,
frames: [ frames: [
@@ -266,8 +273,11 @@ export default class Player extends Phaser.Physics.Arcade.Sprite {
if (this.cursors.up.isDown || this.wasd.W.isDown) vy = -1; if (this.cursors.up.isDown || this.wasd.W.isDown) vy = -1;
else if (this.cursors.down.isDown || this.wasd.S.isDown) vy = 1; else if (this.cursors.down.isDown || this.wasd.S.isDown) vy = 1;
// 摇杆输入优先级高于键盘(forceX/Y ∈ [-1, 1])。 // 摇杆输入优先级高于键盘。
if (this.joystick && this.joystick.forceX !== 0 && this.joystick.forceY !== 0) { // 注意:rex 插件的 forceX/forceY 是"摇杆基点 → 指针"的原始位移(像素,非 [-1,1]),
// 方向由下方归一化保证;noKey 为 false 才代表摇杆真正被推动(含正上/下/左/右,
// 这些方向必有某一轴分量为 0,不能用"两轴同时非零"来判断)。
if (this.joystick && !this.joystick.noKey) {
vx = this.joystick.forceX; vx = this.joystick.forceX;
vy = this.joystick.forceY; vy = this.joystick.forceY;
} }
+74 -27
View File
@@ -9,12 +9,15 @@
* 2. 玩家移动(键盘 + 触摸设备虚拟摇杆)与相机跟随; * 2. 玩家移动(键盘 + 触摸设备虚拟摇杆)与相机跟随;
* 3. 景点触发:Zone overlap 检测 + 弹窗互斥 + 离开冷却(防原地连弹); * 3. 景点触发:Zone overlap 检测 + 弹窗互斥 + 离开冷却(防原地连弹);
* 4. POI 标记状态管理(读档恢复 / 解锁盖章动效)与屏幕边缘引导箭头; * 4. POI 标记状态管理(读档恢复 / 解锁盖章动效)与屏幕边缘引导箭头;
* 5. 与 DOM UI 层通过 eventBus 通信(poi:reached / ui:closed)。 * 5. 与 DOM UI 层通过 eventBus 通信(poi:reached / ui:opened / ui:closed)。
* *
* 主要流程: * 主要流程:
* create() 搭建世界 → update() 每帧驱动玩家与引导 → * create() 搭建世界 → update() 每帧驱动玩家与引导 →
* overlap → poi:reached → PhotoModal 展示 → ui:closed → * overlap → poi:reached → PhotoModal 展示(ui:opened 上锁)→
* unlock + 冷却 + 恢复输入 → 玩家离开触发圈解除冷却。 * ui:closed 解锁 + 冷却 + 恢复输入 → 玩家离开触发圈解除冷却。
*
* 输入锁模型:uiLocks 记录当前持有输入锁的 UI 层数量(封面 1 层 + 每个
* 已显示弹窗/纪念页各 1 层),归零时才恢复游戏输入,避免纪念页背后被穿透操作。
*/ */
import Phaser from 'phaser'; import Phaser from 'phaser';
@@ -52,7 +55,8 @@ export default class MapScene extends Phaser.Scene {
*/ */
create() { create() {
// ── 底图与纸张纹理叠加 ── // ── 底图与纸张纹理叠加 ──
this.add.image(0, 0, 'mapBase').setOrigin(0, 0); // setDisplaySize 保证底图降级为占位图(尺寸不同)时仍铺满整个世界。
this.add.image(0, 0, 'mapBase').setOrigin(0, 0).setDisplaySize(WORLD_W, WORLD_H);
if (hasPaperTexture && this.textures.exists('paper')) { if (hasPaperTexture && this.textures.exists('paper')) {
const paper = this.add const paper = this.add
.image(0, 0, 'paper') .image(0, 0, 'paper')
@@ -69,9 +73,16 @@ export default class MapScene extends Phaser.Scene {
// ── 玩家 ── // ── 玩家 ──
this.player = new Player(this, SPAWN.x, SPAWN.y); this.player = new Player(this, SPAWN.x, SPAWN.y);
this.modalOpen = true; // 初始由封面页持有输入锁。 /** @type {number} 持有输入锁的 UI 层数量(初始由封面页持有 1 层)。 */
this.uiLocks = 1;
this.setGameInputEnabled(false); this.setGameInputEnabled(false);
// ── 景点索引与解锁缓存(避免每帧线性查找与重复分配) ──
/** @type {Map<string, Object>} 景点 id → 配置。 */
this.spotsById = new Map(SPOTS.map((spot) => [spot.id, spot]));
/** @type {Set<string>} 已解锁景点 id 缓存(随 state:changed 更新)。 */
this.unlockedIds = new Set(gameState.getSnapshot().unlocked);
// ── 相机 ── // ── 相机 ──
const camera = this.cameras.main; const camera = this.cameras.main;
camera.setBounds(0, 0, WORLD_W, WORLD_H); camera.setBounds(0, 0, WORLD_W, WORLD_H);
@@ -100,14 +111,18 @@ export default class MapScene extends Phaser.Scene {
this.createVirtualJoystick(); this.createVirtualJoystick();
// ── DOM UI 事件接线 ── // ── DOM UI 事件接线 ──
eventBus.on('ui:closed', ({ spotId }) => this.onModalClosed(spotId)); eventBus.on('ui:opened', () => this.acquireInputLock());
eventBus.on('ui:closed', ({ spotId }) => this.releaseInputLock(spotId));
eventBus.on('state:changed', (snapshot) => {
this.unlockedIds = new Set(snapshot.unlocked);
});
// 首帧前的进度广播:让 HUD 与封面页拿到初始快照。 // 首帧前的进度广播:让 HUD 与封面页拿到初始快照。
eventBus.emit('state:changed', gameState.getSnapshot()); eventBus.emit('state:changed', gameState.getSnapshot());
// 封面"开始旅行"可能早于本场景创建(ESM live binding 回查放行标志)。 // 封面"开始旅行"可能早于本场景创建(ESM live binding 回查放行标志)。
if (inputReleased) { if (inputReleased) {
this.modalOpen = false; this.uiLocks = 0;
this.setGameInputEnabled(true); this.setGameInputEnabled(true);
} }
} }
@@ -153,31 +168,29 @@ export default class MapScene extends Phaser.Scene {
} }
/** /**
* 玩家进入景点触发圈:请求弹窗(互斥 + 冷却过滤)。 * 获取一层 UI 输入锁(弹窗 / 纪念页显示时由 DOM 层广播 ui:opened 触发)。
* *
* @param {Object} spot 景点配置。
* @returns {void} * @returns {void}
*/ */
onEnterZone(spot) { acquireInputLock() {
if (this.modalOpen || this.cooldowns.has(spot.id)) { this.uiLocks += 1;
return;
}
this.modalOpen = true;
this.setGameInputEnabled(false); this.setGameInputEnabled(false);
eventBus.emit('poi:reached', { spot });
} }
/** /**
* 弹窗关闭:解锁景点(首次)、进入冷却、恢复输入。 * 释放一层 UI 输入锁;全部释放后恢复游戏输入。
* 携带 spotId 时顺带处理该景点的解锁与冷却。
* *
* @param {string} spotId 关闭的弹窗对应景点 id。 * @param {string} [spotId] 关闭的弹窗对应景点 id(封面 / 纪念页为空)。
* @returns {void} * @returns {void}
*/ */
onModalClosed(spotId) { releaseInputLock(spotId) {
this.modalOpen = false; this.uiLocks = Math.max(0, this.uiLocks - 1);
if (this.uiLocks === 0) {
this.setGameInputEnabled(true); this.setGameInputEnabled(true);
}
if (!spotId) { if (!spotId) {
return; // 封面页关闭(无景点上下文)。 return; // 封面页 / 纪念页关闭(无景点上下文)。
} }
if (gameState.isUnlocked(spotId)) { if (gameState.isUnlocked(spotId)) {
this.cooldowns.add(spotId); // 重看:同样进入冷却。 this.cooldowns.add(spotId); // 重看:同样进入冷却。
@@ -188,21 +201,50 @@ export default class MapScene extends Phaser.Scene {
this.cooldowns.add(spotId); this.cooldowns.add(spotId);
} }
/**
* 玩家进入景点触发圈:请求弹窗(输入锁 + 冷却过滤)。
*
* 输入锁由 PhotoModal 显示时的 ui:opened 获取,避免重复计数。
*
* @param {Object} spot 景点配置。
* @returns {void}
*/
onEnterZone(spot) {
if (this.uiLocks > 0 || this.cooldowns.has(spot.id)) {
return;
}
eventBus.emit('poi:reached', { spot });
}
/** /**
* 每帧更新:玩家移动、冷却解除检测、引导箭头。 * 每帧更新:玩家移动、冷却解除检测、引导箭头。
* *
* 任一 UI 层持有输入锁(封面 / 弹窗 / 纪念页)时冻结世界逻辑,
* 玩家静止期间冷却与引导目标也不会变化,故可直接跳过。
*
* @param {number} time 场景时间(毫秒)。 * @param {number} time 场景时间(毫秒)。
* @returns {void} * @returns {void}
*/ */
update(time) { update(time) {
if (!this.modalOpen) { if (this.uiLocks > 0) {
return;
}
this.player.update(); this.player.update();
this.updateCooldowns();
this.updateGuide(time);
} }
// 冷却解除:玩家离开触发圈足够远后可再次触发。 /**
if (this.cooldowns.size > 0) { * 冷却解除:玩家离开触发圈足够远后允许再次触发。
*
* @returns {void}
*/
updateCooldowns() {
if (this.cooldowns.size === 0) {
return;
}
this.cooldowns.forEach((id) => { this.cooldowns.forEach((id) => {
const spot = SPOTS.find((s) => s.id === id); const spot = this.spotsById.get(id);
if (!spot) { if (!spot) {
this.cooldowns.delete(id); this.cooldowns.delete(id);
return; return;
@@ -219,12 +261,17 @@ export default class MapScene extends Phaser.Scene {
}); });
} }
// 引导目标:最近的未解锁景点。 /**
const unlocked = new Set(gameState.getSnapshot().unlocked); * 引导箭头:始终指向最近的未解锁景点(全解锁后传 null 隐藏)。
*
* @param {number} time 场景时间(毫秒,用于浮动动画)。
* @returns {void}
*/
updateGuide(time) {
let target = null; let target = null;
let minDist = Infinity; let minDist = Infinity;
SPOTS.forEach((spot) => { SPOTS.forEach((spot) => {
if (unlocked.has(spot.id)) { if (this.unlockedIds.has(spot.id)) {
return; return;
} }
const dist = Phaser.Math.Distance.Between( const dist = Phaser.Math.Distance.Between(
+42 -33
View File
@@ -5,9 +5,9 @@
* - 以手账风进度条加载全部游戏资源,并对缺失资源做占位降级。 * - 以手账风进度条加载全部游戏资源,并对缺失资源做占位降级。
* *
* 核心职责: * 核心职责:
* 1. 加载地图底图(SVG)、纸张纹理(AI 素材)与全部景点照片; * 1. 加载 Phaser 层资源(地图底图 SVG、纸张纹理);景点照片由 DOM 层按需加载;
* 2. 渲染加载进度 UI(纸底圆角进度条 + 标题 + 百分比); * 2. 渲染加载进度 UI(纸底圆角进度条 + 标题 + 百分比);
* 3. loaderror 处理:照片缺失时生成"照片待补"占位纹理,不中断启动; * 3. loaderror 处理:纸张/底图缺失时降级(占位底图 / 关闭纹理叠加),不中断启动;
* 4. 完成后跳转 MapScene。 * 4. 完成后跳转 MapScene。
* *
* 主要流程: * 主要流程:
@@ -15,7 +15,6 @@
*/ */
import Phaser from 'phaser'; import Phaser from 'phaser';
import { SPOTS } from '../data/spots.js';
/** 纸张纹理是否存在(供 MapScene 决定是否叠加纹理层)。 */ /** 纸张纹理是否存在(供 MapScene 决定是否叠加纹理层)。 */
export let hasPaperTexture = false; export let hasPaperTexture = false;
@@ -79,13 +78,16 @@ export default class PreloadScene extends Phaser.Scene {
percentText.setText(`${Math.round(value * 100)}%`); percentText.setText(`${Math.round(value * 100)}%`);
}); });
// ── 缺失资源降级:生成"照片待补"占位纹理 ── // ── 缺失资源降级 ──
// 注意:景点照片不在此预加载。照片由 DOM UI 层用 <img> 按需加载
// (见 PhotoModal / CompleteOverlay),缺失时由 <img> 的 error 事件
// 降级为"照片待补"占位卡;若在此加载会白占大量 Phaser 纹理显存。
this.load.on('loaderror', (file) => { this.load.on('loaderror', (file) => {
console.warn(`[preload] 资源缺失,启用占位降级: ${file.key}`); console.warn(`[preload] 资源缺失,启用占位降级: ${file.key}`);
if (file.key.startsWith('photo-')) { if (file.key === 'paper') {
this.buildPhotoPlaceholder(file.key);
} else if (file.key === 'paper') {
hasPaperTexture = false; hasPaperTexture = false;
} else if (file.key === 'mapBase') {
this.buildMapPlaceholder();
} }
}); });
@@ -93,45 +95,48 @@ export default class PreloadScene extends Phaser.Scene {
hasPaperTexture = true; hasPaperTexture = true;
this.load.image('mapBase', 'assets/maps/map-base.svg'); this.load.image('mapBase', 'assets/maps/map-base.svg');
this.load.image('paper', 'assets/textures/paper.jpg'); this.load.image('paper', 'assets/textures/paper.jpg');
// ── 景点照片(key 约定:photo-{spotId}-{index}) ──
SPOTS.forEach((spot) => {
spot.photos.forEach((photo, index) => {
this.load.image(`photo-${spot.id}-${index}`, photo.src);
});
});
} }
/** /**
* 为缺失照片生成 4:3 渐变占位纹理(含"照片待补"水印)。 * 生成底图缺失时的占位底图(羊皮纸底 + 网格 + 提示文字)。
*
* 按世界比例 10:7 生成,MapScene 通过 setDisplaySize 拉伸铺满整个世界,
* 因此尺寸无需与 map-base.svg 一致。
* *
* @param {string} textureKey 缺失的纹理 key。
* @returns {void} * @returns {void}
*/ */
buildPhotoPlaceholder(textureKey) { buildMapPlaceholder() {
if (this.textures.exists(textureKey)) { const key = 'mapBase';
if (this.textures.exists(key)) {
return; return;
} }
const w = 1200; const w = 1200;
const h = 900; const h = 840;
const canvas = this.textures.createCanvas(textureKey, w, h); const canvas = this.textures.createCanvas(key, w, h);
const ctx = canvas.getContext(); const ctx = canvas.getContext();
const grad = ctx.createLinearGradient(0, 0, w, h); ctx.fillStyle = '#f5ecd7';
grad.addColorStop(0, '#e8d5b5');
grad.addColorStop(1, '#cdb891');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(138, 106, 66, 0.35)'; // 浅棕网格,便于确认坐标系与角色位移。
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(176, 138, 94, 0.3)';
ctx.setLineDash([14, 10]); ctx.lineWidth = 1;
ctx.strokeRect(24, 24, w - 48, h - 48); for (let x = 0; x <= w; x += 60) {
ctx.setLineDash([]); ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y <= h; y += 60) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
ctx.fillStyle = 'rgba(107, 78, 46, 0.75)'; ctx.fillStyle = 'rgba(107, 78, 46, 0.75)';
ctx.font = '48px "Kaiti SC", KaiTi, serif'; ctx.font = '52px "Kaiti SC", KaiTi, serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText('📷 照片待补', w / 2, h / 2 - 12); ctx.fillText('🗺️ 地图底图缺失', w / 2, h / 2 - 8);
ctx.font = '30px "Kaiti SC", KaiTi, serif'; ctx.font = '28px "Kaiti SC", KaiTi, serif';
ctx.fillText('替换真实照片后自动生效', w / 2, h / 2 + 56); ctx.fillText('已启用占位底图,放入 assets/maps/map-base.svg 后自动恢复', w / 2, h / 2 + 56);
canvas.refresh(); canvas.refresh();
} }
@@ -141,6 +146,10 @@ export default class PreloadScene extends Phaser.Scene {
* @returns {void} * @returns {void}
*/ */
create() { create() {
// 底图缺失时补建占位底图,保证 MapScene 不会因纹理缺失而中断。
if (!this.textures.exists('mapBase')) {
this.buildMapPlaceholder();
}
// 略作停顿让进度条走满展示,再转场。 // 略作停顿让进度条走满展示,再转场。
this.time.delayedCall(250, () => this.scene.start('MapScene')); this.time.delayedCall(250, () => this.scene.start('MapScene'));
} }
+21 -6
View File
@@ -5,13 +5,14 @@
* - 全部景点解锁后的全屏纪念页:照片九宫格 + 旅行统计 + 继续漫游。 * - 全部景点解锁后的全屏纪念页:照片九宫格 + 旅行统计 + 继续漫游。
* *
* 核心职责: * 核心职责:
* 1. 监听 game:complete 展示纪念页(每个景点一张 mini 拍立得); * 1. 监听 game:complete 展示纪念页(每个景点一张 mini 拍立得,照片缺失降级为图标);
* 2. 统计口径:景点数、旅行天数(spots 中不重复 date 数量); * 2. 统计口径:景点数、旅行天数(spots 中不重复 date 数量);
* 3. "继续漫游"关闭页面并交还输入(ui:closed)。 * 3. 点击某张照片复用拍立得弹窗放大重看(emit poi:reached);
* 4. 显示时 emit ui:opened 上锁、"继续漫游"时 emit ui:closed 交还输入。
* *
* 主要流程: * 主要流程:
* game-state 检测集齐 → game:complete → show() → 用户浏览/关闭 → * game-state 检测集齐 → game:complete → show() → 用户浏览/点击放大/关闭 →
* emit('ui:closed', {}) → MapScene 恢复输入。 * emit('ui:closed', {}) → MapScene 释放输入锁。
*/ */
import { eventBus } from '../event-bus.js'; import { eventBus } from '../event-bus.js';
@@ -58,14 +59,26 @@ export class CompleteOverlay {
const grid = this.el.querySelector('.co-grid'); const grid = this.el.querySelector('.co-grid');
grid.innerHTML = ''; grid.innerHTML = '';
SPOTS.forEach((spot) => { SPOTS.forEach((spot) => {
const card = document.createElement('div'); const card = document.createElement('button');
card.type = 'button';
card.className = 'co-card'; card.className = 'co-card';
card.style.setProperty('--tilt', `${(spot.x % 11) - 5}deg`); card.style.setProperty('--tilt', `${(spot.x % 11) - 5}deg`);
card.innerHTML = ` card.innerHTML = `
<div class="co-photo">${spot.icon}</div> <div class="co-photo">
<img src="${spot.photos[0].src}" alt="${spot.name}" />
</div>
<div class="co-name">${spot.name}</div> <div class="co-name">${spot.name}</div>
<div class="co-date">${spot.date}</div> <div class="co-date">${spot.date}</div>
`; `;
// 照片缺失时降级为景点图标(与 PhotoModal 的"照片待补"占位同源)。
const img = card.querySelector('img');
img.addEventListener('error', () => {
const fallback = document.createElement('span');
fallback.textContent = spot.icon;
img.replaceWith(fallback);
});
// 点击复用拍立得弹窗放大重看(PhotoModal 监听 poi:reached)。
card.addEventListener('click', () => eventBus.emit('poi:reached', { spot }));
grid.appendChild(card); grid.appendChild(card);
}); });
@@ -73,6 +86,8 @@ export class CompleteOverlay {
this.el.querySelector('#co-days').textContent = days; this.el.querySelector('#co-days').textContent = days;
this.el.querySelector('#co-spots').textContent = SPOTS.length; this.el.querySelector('#co-spots').textContent = SPOTS.length;
this.el.classList.remove('hidden'); this.el.classList.remove('hidden');
// 上锁:纪念页显示期间禁止地图输入;其上叠加的拍立得弹窗会各自再持一层锁。
eventBus.emit('ui:opened');
} }
/** /**
+6 -3
View File
@@ -8,11 +8,12 @@
* 1. 监听 poi:reached 展示景点照片(多张时叠成一沓,点击翻下一张); * 1. 监听 poi:reached 展示景点照片(多张时叠成一沓,点击翻下一张);
* 2. 拍立得视觉:白边相纸 + 手写 caption + 角标日期 + 轻微随机旋转; * 2. 拍立得视觉:白边相纸 + 手写 caption + 角标日期 + 轻微随机旋转;
* 3. 照片缺失降级:img 加载失败时替换为"照片待补"占位卡; * 3. 照片缺失降级:img 加载失败时替换为"照片待补"占位卡;
* 4. 关闭(按钮 / 遮罩 / ESC / 空格)后广播 ui:closed 交还输入。 * 4. 显示时广播 ui:opened 上锁,关闭(按钮 / 遮罩 / ESC / 空格)后广播
* ui:closed 交还输入。
* *
* 主要流程: * 主要流程:
* MapScene overlap → poi:reached → show(spot) → 用户翻看/关闭 → * MapScene overlap / 纪念页点击 → poi:reached → show(spot)(ui:opened)→
* emit('ui:closed', { spotId }) → MapScene 解锁并进入冷却。 * 用户翻看/关闭 → emit('ui:closed', { spotId }) → MapScene 解锁并进入冷却。
*/ */
import { eventBus } from '../event-bus.js'; import { eventBus } from '../event-bus.js';
@@ -79,6 +80,8 @@ export class PhotoModal {
this.el.querySelector('.pm-story').textContent = spot.story; this.el.querySelector('.pm-story').textContent = spot.story;
this.el.querySelector('.pm-badge').hidden = gameState.isUnlocked(spot.id); this.el.querySelector('.pm-badge').hidden = gameState.isUnlocked(spot.id);
this.el.classList.remove('hidden'); this.el.classList.remove('hidden');
// 上锁:弹窗显示期间禁止地图输入(与纪念页等其它 UI 层各自持锁)。
eventBus.emit('ui:opened');
this.keyHandler = (e) => { this.keyHandler = (e) => {
if (e.key === 'Escape' || e.key === ' ') { if (e.key === 'Escape' || e.key === ' ') {
+41 -4
View File
@@ -464,6 +464,11 @@ body {
border-radius: 999px; border-radius: 999px;
padding: 0.35em 1.1em; padding: 0.35em 1.1em;
box-shadow: 0 3px 10px rgba(60, 42, 20, 0.25); box-shadow: 0 3px 10px rgba(60, 42, 20, 0.25);
}
/* HUD 为纯信息展示层,不拦截指针。
注意:#ui > * 的特异性(1,0,0)高于 .hud(0,1,0),必须用同等特异性覆盖。 */
#ui > .hud {
pointer-events: none; pointer-events: none;
} }
@@ -566,42 +571,74 @@ body {
letter-spacing: 0.2em; letter-spacing: 0.2em;
} }
/* 九宫格(每景点一张 mini 拍立得)。 */ /* 九宫格(每景点一张 mini 拍立得)。
必须给定宽度:.co-content 是 align-items:center 的收缩容器,
若不定宽,含 <img> 的网格会按图片固有宽度撑爆布局。 */
.co-grid { .co-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: clamp(10px, 1.8vmin, 18px); gap: clamp(10px, 1.8vmin, 18px);
margin-top: clamp(6px, 1.4vmin, 14px); margin-top: clamp(6px, 1.4vmin, 14px);
width: min(88vw, 1100px);
} }
/* 小屏(手机横屏)降为两行布局。 */ /* 小屏(手机横屏)收紧间距。 */
@media (max-width: 860px) { @media (max-width: 860px) {
.co-grid { .co-grid {
grid-template-columns: repeat(5, 1fr);
gap: 8px; gap: 8px;
} }
} }
.co-card { .co-card {
font: inherit;
color: inherit;
border: none;
cursor: pointer;
background: var(--card); background: var(--card);
padding: 8% 6% 12%; padding: 8% 6% 12%;
box-shadow: 0 3px 10px rgba(60, 42, 20, 0.28); box-shadow: 0 3px 10px rgba(60, 42, 20, 0.28);
transform: rotate(var(--tilt, 0deg)); transform: rotate(var(--tilt, 0deg));
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.co-card:hover,
.co-card:focus-visible {
transform: rotate(var(--tilt, 0deg)) translateY(-4px) scale(1.05);
box-shadow: 0 9px 20px rgba(60, 42, 20, 0.35);
outline: none;
}
.co-photo { .co-photo {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 2px;
overflow: hidden;
background: var(--paper-deep);
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(24px, 4.6vmin, 46px); font-size: clamp(24px, 4.6vmin, 46px);
} }
.co-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.co-name { .co-name {
font-family: var(--font-hand); font-family: var(--font-hand);
font-size: clamp(11px, 1.7vmin, 17px); font-size: clamp(11px, 1.7vmin, 17px);
color: var(--ink); color: var(--ink);
white-space: nowrap; white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
} }
.co-date { .co-date {