fix: 修复批量缺陷并优化性能与交互体验
修复移动端摇杆输入失效问题,修正Rex摇杆的力值使用逻辑 重构输入互斥为计数模型,新增ui:opened事件解决纪念页穿透输入问题 修复引导箭头首帧闪现问题,设置初始透明度过渡 修复hasSave误判存档问题,新增合法景点id校验集 修复底图加载失败白屏问题,新增占位底图降级逻辑 移除无用的Phaser照片预加载,节省约65MB纹理显存 优化性能:缓存景点索引与解锁集合,跳过UI锁期间的世界更新 升级纪念页支持点击照片复用拍立得弹窗放大,新增照片降级 修复HUD指针事件不生效的CSS特异性问题 同步更新事件协议文档与输入锁模型说明
This commit is contained in:
1 parent
a404e42ca8
commit
b0467054c8
11 files changed
+254
-103
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-44
@@ -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);
|
||||||
this.setGameInputEnabled(true);
|
if (this.uiLocks === 0) {
|
||||||
|
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,43 +201,77 @@ 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) {
|
||||||
this.player.update();
|
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);
|
* @returns {void}
|
||||||
if (!spot) {
|
*/
|
||||||
this.cooldowns.delete(id);
|
updateCooldowns() {
|
||||||
return;
|
if (this.cooldowns.size === 0) {
|
||||||
}
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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 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
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user