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