commit a404e42ca8cb14b46518216f45c69512ef2696b7 Author: spongebob Date: Sat Oct 17 18:01:46 2026 +0800 feat: 初始化西安旅行记忆互动地图游戏项目 完成项目基础搭建:配置 Vite 开发构建环境,集成 Phaser 3 与虚拟摇杆插件,实现地图漫游、景点触发、拍立得弹窗、进度存档等核心功能,添加占位资源与批量生成脚本,配置项目元数据与忽略规则。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eee94a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# ── 依赖与构建产物 ── +node_modules/ +dist/ +.vite/ +.cache/ +.parcel-cache/ +coverage/ + +# ── Capacitor / Android(M6 打包阶段) ── +android/.gradle/ +android/app/build/ +android/build/ +android/capacitor-cordova-android-plugins/build/ +android/local.properties +android/app/src/main/assets/public/ +*.keystore +!android/app/debug.keystore + +# ── 头脑风暴视觉伴侣会话(本地临时) ── +.superpowers/ + +# ── 环境与密钥 ── +.env +.env.* +*.pem + +# ── 日志 ── +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ── 编辑器与系统文件 ── +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-workspace +.history/ +.DS_Store +Thumbs.db +desktop.ini + +# ── 临时与备份文件 ── +tmp/ +*~ +*.tmp +*.bak +.~lock.* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db87116 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# 变更记录(CHANGELOG) + +本项目所有显著变更均记录于此。 +版本号:`vYYYY.MM.DD-XX`;类型标签:[Add] / [Change] / [Fix] / [Remove] / [Doc] / [Chore] + +## v2026.10.16-01 + +首次完整实现(M1–M3):可玩核心 + 完整体验 + 手账美化(全占位资源)。 + +- [Doc] 新增 `docs/spec.md`:经需求澄清与设计确认的细化技术规格(12 项决策记录、分层架构、数据模型、验收标准、M1–M6 交付路线、照片替换指南) +- [Add] 项目脚手架:Vite 5 + Phaser 3 + phaser3-rex-plugins;`base: './'` 相对路径,适配 GitHub Pages / Vercel 静态托管 +- [Add] 混合架构落地:Phaser 地图层(PreloadScene / MapScene 双场景)+ DOM UI 层,经 event-bus 事件桥解耦通信 +- [Add] 手账风西安地图:3000×2100 SVG 底图(明城墙 + 护城河、渭/灞/浐/沣水系、主干路网、罗盘、装饰边框),叠加 AI 纸张纹理(multiply 混合) +- [Add] 玩家角色:程序生成贴纸小人(4 方向 × 2 帧弹性行走动画),AI 精灵表就绪后可整体替换 +- [Add] 玩法系统:WASD/方向键 + 移动端虚拟摇杆;景点触发冷却防连弹;localStorage 进度持久化(隐私模式内存降级);屏幕边缘方向引导箭头(目标入视野自动淡出) +- [Add] DOM UI 组件:手账封面页(胶带/拍立得装饰、继续旅行与重新开始)、拍立得照片弹窗(叠沓翻看、手写文案、日期角标、照片缺失降级占位卡)、HUD 收集进度徽章、全解锁纪念页(照片九宫格 + 旅程统计)、竖屏旋转提示 +- [Add] 数据驱动内容源:`src/data/spots.js` 十景点配置(坐标/照片/文案);`scripts/gen-placeholder-photos.mjs` 占位照片生成脚本(本次生成 15 张 4:3 手账风占位 SVG) +- [Chore] 新增 `.gitignore`:依赖与构建产物、Capacitor/Android 打包产物、环境密钥、日志、编辑器与系统临时文件 diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..7aa2891 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,612 @@ +# 西安旅行记忆互动地图游戏 — AI 开发指导文档 + +> **技术栈**:Phaser 3 + Vite + Capacitor +> **目标平台**:PC 浏览器、Android App +> **核心玩法**:玩家控制角色在西安旅游地图上移动,抵达景点触发照片弹窗 +> **文档用途**:可直接作为 AI 编程工具(Cursor / Copilot / Claude Code 等)的项目开发规格说明 + +--- + +## 1. 项目概述 + +开发一款 2D 俯视角互动地图游戏,将国庆期间在西安拍摄的照片与地图景点绑定。玩家通过键盘(PC)或虚拟摇杆(移动端)控制角色移动,当角色进入景点触发区域时,弹出对应照片和描述文字。项目需支持跨平台运行,并方便通过链接或 APK 分享。 + +--- + +## 2. 技术选型 + +| 模块 | 技术 | 说明 | +|---|---|---| +| 游戏引擎 | Phaser 3 | HTML5 2D 游戏框架,内置物理、输入、场景管理 | +| 构建工具 | Vite | 快速开发与构建,支持 ES 模块 | +| 移动端打包 | Capacitor | 将 Web 应用包装为原生 Android/iOS 应用 | +| UI 插件 | phaser3-rex-plugins | 提供对话框、虚拟摇杆等组件 | +| 地图编辑(可选) | Tiled Map Editor | 导出 JSON 瓦片地图,Phaser 直接加载 | +| 部署托管 | GitHub Pages / Vercel / Netlify | 免费静态托管,支持 HTTPS | + +--- + +## 3. 项目结构 + +``` +xian-travel-game/ +├── public/ +│ ├── assets/ +│ │ ├── maps/ # 西安地图图片(xian-map.png) +│ │ ├── photos/ # 景点照片(按景点命名) +│ │ ├── sprites/ # 角色精灵图(player.png) +│ │ └── audio/ # 背景音乐/音效(可选) +│ └── index.html +├── src/ +│ ├── main.js # 游戏入口与全局配置 +│ ├── scenes/ +│ │ ├── BootScene.js # 资源预加载 +│ │ ├── MapScene.js # 地图主场景 +│ │ └── PhotoScene.js # 照片弹窗场景 +│ ├── objects/ +│ │ ├── Player.js # 玩家角色类 +│ │ └── POI.js # 景点触发点类(可选) +│ └── data/ +│ └── spots.js # 景点坐标、照片、描述配置 +├── capacitor.config.ts +├── vite.config.js +├── package.json +└── index.html +``` + +--- + +## 4. 环境搭建与依赖安装 + +```bash +# 创建 Vite 项目 +npm create vite@latest xian-travel-game -- --template vanilla +cd xian-travel-game + +# 安装 Phaser 与 RexUI 插件 +npm install phaser phaser3-rex-plugins + +# 安装 Capacitor(用于 Android 打包) +npm install @capacitor/core @capacitor/cli @capacitor/android + +# 初始化 Capacitor +npx cap init +``` + +`vite.config.js` 基础配置: + +```js +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', // 相对路径,便于部署到子目录 + build: { + outDir: 'dist', + assetsInlineLimit: 0 // 避免大图片被内联 + }, + server: { + host: true, // 允许局域网访问,方便手机调试 + port: 3000 + } +}); +``` + +--- + +## 5. 核心代码实现 + +### 5.1 入口与全局配置(`src/main.js`) + +```js +import Phaser from 'phaser'; +import RexUIPlugin from 'phaser3-rex-plugins/templates/ui/ui-plugin.js'; +import VirtualJoystickPlugin from 'phaser3-rex-plugins/plugins/virtualjoystick-plugin.js'; + +import BootScene from './scenes/BootScene.js'; +import MapScene from './scenes/MapScene.js'; +import PhotoScene from './scenes/PhotoScene.js'; + +const config = { + type: Phaser.AUTO, + scale: { + mode: Phaser.Scale.FIT, // 自适应屏幕,保持宽高比 + autoCenter: Phaser.Scale.CENTER_BOTH, + width: 1280, + height: 720, + parent: 'game-container' + }, + physics: { + default: 'arcade', + arcade: { + gravity: { y: 0 }, + debug: false + } + }, + plugins: { + scene: [ + { key: 'rexUI', plugin: RexUIPlugin, mapping: 'rexUI' }, + { key: 'rexVirtualJoystick', plugin: VirtualJoystickPlugin, mapping: 'rexVirtualJoystick' } + ] + }, + scene: [BootScene, MapScene, PhotoScene] +}; + +new Phaser.Game(config); +``` + +`index.html` 需包含游戏容器和移动端 meta: + +```html + + + + + + 西安旅行记忆 + + + +
+ + + +``` + +### 5.2 资源加载(`src/scenes/BootScene.js`) + +```js +import Phaser from 'phaser'; +import { SPOTS } from '../data/spots.js'; + +export default class BootScene extends Phaser.Scene { + constructor() { + super({ key: 'BootScene' }); + } + + preload() { + // 显示加载进度 + const width = this.scale.width; + const height = this.scale.height; + const progressBar = this.add.graphics(); + const progressBox = this.add.graphics(); + progressBox.fillStyle(0x222222, 0.8); + progressBox.fillRect(width / 2 - 160, height / 2 - 25, 320, 50); + + this.load.on('progress', (value) => { + progressBar.clear(); + progressBar.fillStyle(0xffffff, 1); + progressBar.fillRect(width / 2 - 150, height / 2 - 15, 300 * value, 30); + }); + + // 加载地图 + this.load.image('xianMap', 'assets/maps/xian-map.png'); + + // 加载角色精灵图(假设为 4 方向行走图,每帧 32x48) + this.load.spritesheet('player', 'assets/sprites/player.png', { + frameWidth: 32, + frameHeight: 48 + }); + + // 加载所有景点照片 + SPOTS.forEach(spot => { + spot.photos.forEach((photoPath, index) => { + this.load.image(`${spot.id}_${index}`, photoPath); + }); + }); + + // 可选:加载背景音乐 + // this.load.audio('bgm', 'assets/audio/bgm.mp3'); + } + + create() { + // 创建角色动画(示例:向下走) + this.anims.create({ + key: 'walk-down', + frames: this.anims.generateFrameNumbers('player', { start: 0, end: 2 }), + frameRate: 8, + repeat: -1 + }); + // 其他方向动画按需添加... + + this.scene.start('MapScene'); + } +} +``` + +### 5.3 地图主场景(`src/scenes/MapScene.js`) + +```js +import Phaser from 'phaser'; +import Player from '../objects/Player.js'; +import { SPOTS } from '../data/spots.js'; + +export default class MapScene extends Phaser.Scene { + constructor() { + super({ key: 'MapScene' }); + } + + create() { + // 1. 添加地图背景 + this.map = this.add.image(0, 0, 'xianMap').setOrigin(0, 0); + + // 2. 设置物理世界边界为地图尺寸 + this.physics.world.setBounds(0, 0, this.map.width, this.map.height); + + // 3. 创建玩家 + this.player = new Player(this, 400, 300); + + // 4. 摄像机跟随玩家 + this.cameras.main.startFollow(this.player); + this.cameras.main.setBounds(0, 0, this.map.width, this.map.height); + + // 5. 创建虚拟摇杆(移动端) + this.createVirtualJoystick(); + + // 6. 创建景点触发区域 + this.createSpotTriggers(); + + // 7. 可选:播放背景音乐 + // this.bgm = this.sound.add('bgm', { loop: true, volume: 0.5 }); + // this.bgm.play(); + } + + createVirtualJoystick() { + // 使用 rexVirtualJoystick 插件 + this.joystick = this.rexVirtualJoystick.add(this, { + x: 120, + y: this.scale.height - 120, + radius: 60, + base: this.add.circle(0, 0, 60, 0x888888, 0.3), + thumb: this.add.circle(0, 0, 30, 0xcccccc, 0.6), + enable: true + }); + + // 将摇杆传递给玩家 + this.player.setJoystick(this.joystick); + } + + createSpotTriggers() { + SPOTS.forEach(spot => { + // 添加景点标记(半透明圆圈 + 名称) + this.add.circle(spot.x, spot.y, spot.radius, 0xffaa00, 0.2); + this.add.text(spot.x, spot.y - spot.radius - 20, spot.name, { + fontSize: '16px', + color: '#ffffff', + backgroundColor: '#00000088', + padding: { x: 6, y: 3 } + }).setOrigin(0.5); + + // 创建触发区域(Zone + 静态物理体) + const zone = this.add.zone(spot.x, spot.y, spot.radius * 2, spot.radius * 2); + this.physics.add.existing(zone, true); // static body + + // 重叠检测:玩家进入区域时弹出照片 + this.physics.add.overlap(this.player, zone, () => { + if (!this.photoOpen) { + this.photoOpen = true; + this.scene.pause(); + this.scene.launch('PhotoScene', { spot }); + } + }); + }); + + // 监听照片场景关闭,恢复主场景 + this.events.on('resume', () => { + this.photoOpen = false; + }); + } + + update() { + this.player.update(); + } +} +``` + +### 5.4 玩家类(`src/objects/Player.js`) + +```js +import Phaser from 'phaser'; + +export default class Player extends Phaser.Physics.Arcade.Sprite { + constructor(scene, x, y) { + super(scene, x, y, 'player'); + scene.add.existing(this); + scene.physics.add.existing(this); + + this.speed = 200; + this.setCollideWorldBounds(true); + + // 键盘输入 + this.cursors = scene.input.keyboard.createCursorKeys(); + this.wasd = scene.input.keyboard.addKeys('W,A,S,D'); + + // 虚拟摇杆(由外部注入) + this.joystick = null; + } + + setJoystick(joystick) { + this.joystick = joystick; + } + + update() { + let vx = 0; + let vy = 0; + + // 键盘输入 + if (this.cursors.left.isDown || this.wasd.A.isDown) vx = -1; + else if (this.cursors.right.isDown || this.wasd.D.isDown) vx = 1; + if (this.cursors.up.isDown || this.wasd.W.isDown) vy = -1; + else if (this.cursors.down.isDown || this.wasd.S.isDown) vy = 1; + + // 虚拟摇杆输入(优先级高于键盘) + if (this.joystick && this.joystick.isPressed) { + vx = this.joystick.forceX; + vy = this.joystick.forceY; + } + + // 归一化速度,避免斜向移动过快 + const len = Math.sqrt(vx * vx + vy * vy); + if (len > 0) { + this.setVelocity((vx / len) * this.speed, (vy / len) * this.speed); + // 播放行走动画(根据方向切换) + // this.anims.play('walk-down', true); // 示例 + } else { + this.setVelocity(0, 0); + this.anims.stop(); + } + } +} +``` + +### 5.5 景点数据(`src/data/spots.js`) + +```js +export const SPOTS = [ + { + id: 'dayanta', + name: '大雁塔', + x: 820, + y: 450, + radius: 60, + photos: [ + 'assets/photos/dayanta_01.jpg', + 'assets/photos/dayanta_02.jpg' + ], + description: '国庆第3天,夕阳下的大雁塔格外壮观。' + }, + { + id: 'bingmayong', + name: '兵马俑', + x: 1500, + y: 200, + radius: 70, + photos: [ + 'assets/photos/bingmayong_01.jpg' + ], + description: '世界第八大奇迹,震撼到说不出话。' + }, + { + id: 'zhonglou', + name: '钟楼', + x: 600, + y: 350, + radius: 50, + photos: [ + 'assets/photos/zhonglou_01.jpg' + ], + description: '夜晚的钟楼灯火辉煌,车水马龙。' + } + // ... 按实际照片添加更多景点 +]; +``` + +> **坐标说明**:`x, y` 为地图图片上的像素坐标。可用图片编辑工具或在线取点工具获取。 + +### 5.6 照片弹窗场景(`src/scenes/PhotoScene.js`) + +```js +import Phaser from 'phaser'; + +export default class PhotoScene extends Phaser.Scene { + constructor() { + super({ key: 'PhotoScene' }); + } + + init(data) { + this.spot = data.spot; + } + + create() { + const width = this.scale.width; + const height = this.scale.height; + + // 半透明遮罩,阻止点击穿透 + const overlay = this.add.rectangle(0, 0, width, height, 0x000000, 0.85) + .setOrigin(0, 0) + .setInteractive(); + + // 照片显示(取第一张照片,可扩展为轮播) + const photoKey = `${this.spot.id}_0`; + const photo = this.add.image(width / 2, height / 2 - 40, photoKey); + // 等比例缩放至合适大小 + const maxW = width * 0.7; + const maxH = height * 0.6; + const scale = Math.min(maxW / photo.width, maxH / photo.height, 1); + photo.setScale(scale); + + // 景点名称 + this.add.text(width / 2, height / 2 - maxH / 2 - 60, this.spot.name, { + fontSize: '32px', + color: '#ffffff', + fontStyle: 'bold' + }).setOrigin(0.5); + + // 描述文字 + this.add.text(width / 2, height / 2 + maxH / 2 + 30, this.spot.description, { + fontSize: '18px', + color: '#cccccc', + wordWrap: { width: width * 0.7 }, + align: 'center' + }).setOrigin(0.5); + + // 点击任意位置关闭 + overlay.on('pointerdown', () => { + this.scene.stop(); + this.scene.resume('MapScene'); + }); + + // 也支持按 ESC / 空格关闭(PC) + this.input.keyboard.once('keydown-ESC', () => { + this.scene.stop(); + this.scene.resume('MapScene'); + }); + this.input.keyboard.once('keydown-SPACE', () => { + this.scene.stop(); + this.scene.resume('MapScene'); + }); + } +} +``` + +--- + +## 6. 资源准备规范 + +### 6.1 地图底图 + +- 格式:PNG 或 JPG,宽度建议 2000–4000px +- 来源:陕西省测绘地理信息局标准地图、城市吧旅游地图,或自行风格化处理 +- 存放:`public/assets/maps/xian-map.png` + +### 6.2 景点照片 + +- 按景点分类,每个景点 1–5 张 +- 统一压缩至宽度 1200px 以内,推荐 WebP 格式 +- 命名:`景点id_序号.jpg`(如 `dayanta_01.jpg`) +- 存放:`public/assets/photos/` + +### 6.3 角色精灵图 + +- 格式:PNG 精灵图,每帧 32×48 或 48×64 +- 至少包含 4 方向行走帧 +- 存放:`public/assets/sprites/player.png` + +--- + +## 7. 移动端适配 + +- **缩放模式**:`Phaser.Scale.FIT` + `autoCenter: CENTER_BOTH` +- **禁止滚动**:CSS `touch-action: none; overflow: hidden;` +- **虚拟摇杆**:使用 `rexVirtualJoystick`,位于屏幕左下角 +- **屏幕旋转**:监听 `orientationchange` 并调用 `game.scale.refresh()` +- **安全区域**:可使用 `viewport-fit=cover` 和 CSS `env(safe-area-inset-*)` 处理刘海屏 + +--- + +## 8. 打包 Android App(Capacitor) + +1. 构建 Web 版本: + ```bash + npm run build + ``` + +2. 配置 `capacitor.config.ts`: + ```ts + import { CapacitorConfig } from '@capacitor/cli'; + + const config: CapacitorConfig = { + appId: 'com.yourname.xianTravel', + appName: '西安旅行记忆', + webDir: 'dist', + server: { androidScheme: 'https' } + }; + export default config; + ``` + +3. 添加 Android 平台并同步: + ```bash + npx cap add android + npx cap sync + ``` + +4. 用 Android Studio 打开并生成 APK: + ```bash + npx cap open android + ``` + 在 Android Studio 中点击 **Build → Build Bundle(s) / APK(s) → Build APK(s)**。 + +--- + +## 9. 部署与分享 + +### 9.1 GitHub Pages(免费链接分享) + +```bash +npm run build +npx gh-pages -d dist +``` +在 GitHub 仓库 **Settings → Pages** 中,将 Source 设为 `gh-pages` 分支。 +若部署在子路径,需在 `vite.config.js` 中设置 `base: '/仓库名/'`。 + +### 9.2 Vercel / Netlify(自动部署 + 自定义域名) + +- Build command:`npm run build` +- Publish directory:`dist` +- 连接 GitHub 仓库后自动部署,分配 `https://项目名.vercel.app` 域名。 + +### 9.3 APK 直接分享 + +通过 Capacitor 生成的 APK 可通过微信、QQ 直接发送安装。 + +--- + +## 10. 性能优化清单 + +| 优化项 | 做法 | +|---|---| +| 图片压缩 | 照片转 WebP,宽度 ≤1200px | +| 按需加载 | 照片在 BootScene 中统一加载,或进入景点时动态加载 | +| 对象池 | 弹窗场景复用,避免频繁创建销毁 | +| 缓存引用 | 将 `this.player` 等存为类属性,避免每帧查找 | +| 选择性更新 | 仅当玩家移动时更新摇杆逻辑 | +| 渲染模式 | 低端设备可尝试 `Phaser.CANVAS` 替代 `Phaser.AUTO` | +| 世界边界 | 设置 `physics.world.setBounds` 避免角色跑出地图 | + +--- + +## 11. 开发时间估算 + +| 阶段 | 内容 | 预计时间 | +|---|---|---| +| 项目搭建 | Vite + Phaser + Capacitor 初始化 | 0.5 天 | +| 地图与角色 | 地图加载、角色移动、边界碰撞 | 1 天 | +| 景点系统 | 触发区域、照片弹窗、描述文字 | 1 天 | +| 移动端适配 | 虚拟摇杆、缩放适配、横屏处理 | 0.5–1 天 | +| 打包与部署 | Capacitor 打包 APK、GitHub Pages 部署 | 0.5 天 | +| 测试调优 | 真机测试、性能优化、Bug 修复 | 1 天 | +| **合计** | | **4.5–5 天** | + +--- + +## 12. AI 编程工具使用建议 + +- 将本文件作为项目根目录的 `README.md` 或 `SPEC.md`,让 AI 工具读取上下文。 +- 按章节逐步实现,每完成一个模块运行 `npm run dev` 验证。 +- 遇到具体错误时,将错误信息与相关代码段一并提供给 AI 工具。 +- 地图坐标和照片路径需根据实际资源手动调整,AI 工具无法自动获取。 +- 虚拟摇杆的 `rexVirtualJoystick` 用法可参考 [phaser3-rex-plugins 官方文档](https://rexrainbow.github.io/phaser3-rex-notes/docs/site/virtualjoystick/)。 + +--- diff --git a/docs/spec.md b/docs/spec.md new file mode 100644 index 0000000..e8cbe23 --- /dev/null +++ b/docs/spec.md @@ -0,0 +1,339 @@ +# 西安旅行记忆互动地图游戏 · 技术规格说明(细化版) + +> 本文档是 `docs/overview.md`(原始需求)经过需求澄清与设计确认后的细化规格,作为后续实现的唯一依据。 +> 日期:2026-10-16 · 状态:已与需求方逐节确认 + +--- + +## 1. 项目概述 + +一款 2D 俯视角互动地图游戏:玩家控制 Q 版小人在手绘手账风的西安地图上漫游,走进景点触发区即弹出"拍立得照片"回忆卡。集齐全部景点后解锁"旅行完成"纪念页。 + +- **一句话玩法**:走地图 → 触发景点 → 翻拍立得照片 → 集齐进度 → 纪念页 +- **目标平台**:手机/PC 浏览器(Web 优先交付);Android APK 为第二阶段 +- **分享形态**:URL 链接即玩;后期 APK 微信分发 +- **情感定位**:一本"可以走进去的旅行手账",暖色调、手写感、记忆感 + +--- + +## 2. 已确认决策记录 + +| # | 决策项 | 结论 | 备注 | +|---|---|---|---| +| 1 | 照片资源 | 暂未准备 | 占位图先行开发,后期按《照片替换指南》换真实照片 | +| 2 | 地图范围 | 主城区 + 经典景点 | 约 8–12 个景点,地图 3000×2100 viewBox | +| 3 | 玩法深度 | 收集进度 + 方向引导 | 屏幕边缘箭头指向最近未解锁景点 | +| 4 | 地图视觉 | 手绘旅行手账风 | 羊皮纸底色 + 棕褐线条 + 手绘图标 | +| 5 | 底图制作 | AI 素材 + SVG 路网混合 | 位置准确性归 SVG,氛围归 AI 生成素材 | +| 6 | 角色形象 | Q 版小人精灵图 | AI 生成 4 方向 × 3 帧精灵表,帧格 48×64 | +| 7 | 照片弹窗 | 拍立得照片风 | 白边相纸 + 手写描述 + 轻微旋转,多照片叠沓翻看 | +| 8 | 进度存档 | localStorage 持久化 | 可重置 | +| 9 | 开场页 | 手账风封面页 | "开始/继续旅行" + 进度概览 | +| 10 | 声音 | 暂不做 | 后期想加再加(预留 audio 目录) | +| 11 | 交付顺序 | Web 优先,APK 后置 | M1–M5 为 Web,M6 为 Capacitor 打包 | +| 12 | 架构 | Phaser 地图层 + DOM UI 层混合 | 取代原 overview.md 的纯 Phaser 场景方案 | + +--- + +## 3. 架构设计 + +### 3.1 分层 + +``` +index.html +├── #game → Phaser Canvas 层(下层):游戏世界 +├── #ui → DOM + CSS UI 层(上层,pointer-events 按需开合) +└── 横切模块:event-bus.js(事件桥)、game-state.js(存档)、data/spots.js(内容配置) +``` + +**Phaser 层**(仅两个场景,较原方案砍掉 PhotoScene): + +- `PreloadScene`:进度条 + 资源预加载,完成后自动进入 MapScene +- `MapScene`:地图渲染、相机跟随、角色精灵与动画、键盘/摇杆输入、景点触发检测(Zone overlap)、方向引导箭头渲染 + +**DOM UI 层**(纯 DOM 组件,零 Phaser 依赖): + +- `TitleOverlay`:手账封面页(开始/继续旅行、进度概览、重新开始) +- `PhotoModal`:拍立得弹窗(照片叠沓、翻看、手写描述、关闭) +- `Hud`:收集进度徽章(如 `5/12`)+ 已解锁数量 +- `CompleteOverlay`:100% 纪念页(照片九宫格 + 旅行统计 + 继续漫游) +- `dom-layer.js`:层管理器,负责显示/隐藏/互斥/覆盖 + +### 3.2 事件桥(EventBus) + +自写轻量事件模块(约 20 行,`on/once/off/emit`)。跨层事件协议: + +| 事件名 | 方向 | 载荷 | 语义 | +|---|---|---|---| +| `poi:reached` | Phaser → UI | `spot` 配置对象 | 玩家进入景点触发区,请求弹窗 | +| `ui:closed` | UI → Phaser | 无 | 弹窗/封面关闭,恢复游戏输入 | +| `state:changed` | game-state → 两层 | 进度快照 | 解锁变化,刷新 HUD 与地图标记 | +| `game:complete` | game-state → UI | 进度快照 | 全部解锁,弹出纪念页 | + +### 3.3 输入互斥(弹窗打开时) + +双保险: + +1. DOM 层全屏遮罩拦截所有触摸/点击(`pointer-events: auto`) +2. `game.input.enabled = false` 禁用 Phaser 输入(摇杆同时失效) + +关闭弹窗后反向恢复。弹窗期间 MapScene 不 pause(`scene.pause` 也不再需要,简化状态机)。 + +### 3.4 数据流 + +``` +spots.js(静态配置) ──┐ + ├→ MapScene(渲染地图/标记/触发) ──poi:reached→ PhotoModal(展示照片) +game-state.js ──state:changed──┴→ Hud(进度徽章) / PoiMarker(亮色标记) / TitleOverlay(进度概览) + ↑ localStorage 读写 +``` + +--- + +## 4. 游戏流程(状态流转) + +``` +打开页面 + → PreloadScene(加载进度条) + → TitleOverlay(手账封面) + ├─ 首次: "开始旅行" + └─ 有存档: "继续旅行 (5/12)" + "重新开始" + → MapScene 漫游(WASD/方向键/摇杆) + ├─ 进入未解锁景点触发区 → PhotoModal(拍立得, 首张) + │ → 关闭 → 解锁标记盖章动效 + HUD +1 → state:changed → 存档 + ├─ 进入已解锁景点触发区 → PhotoModal(重看, 不重复计数) + └─ 屏幕边缘箭头始终指向最近未解锁景点 + → 全部解锁 → CompleteOverlay(纪念页: 照片九宫格+统计) + └─ "继续漫游" → 回到地图(引导箭头消失, 标记全亮) +``` + +**触发防抖规则**:关闭弹窗后,玩家必须离开该景点触发区,才能再次触发该景点(防止原地连弹)。实现:记录 `activePoi` 与 `cooldown` 标记,overlap 回调中判断。 + +--- + +## 5. 数据模型 + +### 5.1 景点配置 `src/data/spots.js`(唯一内容源) + +```js +/** + * 景点清单:坐标为 SVG 底图 viewBox 坐标系单位。 + * 替换真实照片时:同名覆盖文件 + 更新 photos[].caption / story / date。 + */ +export const SPOTS = [ + { + id: 'dayanta', // 唯一 ID,存档键使用 + name: '大雁塔', + x: 1820, y: 1450, // 地图像素坐标(SVG viewBox 单位) + radius: 70, // 触发半径 + icon: '🗼', // 地图标记图标 + photos: [ // 1–5 张,弹窗叠沓翻看 + { src: 'assets/photos/dayanta_01.jpg', caption: '夕阳下的大雁塔' }, + ], + story: '国庆第 3 天,我们在塔下等灯光亮起。', + date: '10月3日', // 拍立得右下角手写日期 + }, + // ... 共 8–12 个景点;M1 内置 10 个景点占位数据完整跑通,后期随真实照片增删 +]; +``` + +### 5.2 存档结构 `src/game-state.js` + +- localStorage key:`xian-travel-progress` +- 结构:`{ unlocked: string[], updatedAt: number }` +- API:`loadProgress()` / `unlock(spotId)` / `resetProgress()` / `isUnlocked(spotId)` / `getSnapshot()` +- 隐私模式降级:localStorage 不可用时回退内存 Map,会话内有效,封面页不显示"继续" + +### 5.3 占位资源规范 + +| 资源 | 占位形态 | 替换路径 | +|---|---|---| +| 照片 | 4:3 渐变 SVG 色块(每个景点不同色相,含景点名水印) | 同名覆盖 + 更新 caption | +| 底图 | 简化 SVG 路网(城墙环/主干道/水系/POI 点) | 路网不动,叠加 AI 纹理层 | +| 角色 | 程序绘制方向指示标记 | AI 精灵表替换加载 key | +| 纪念页九宫格 | 复用照片占位色块 | 随照片自动生效 | + +--- + +## 6. 地图与美术制作管线 + +1. **参照层**:高德/OSM 截图仅作绘制参照,不进仓库(规避版权) +2. **SVG 路网层**(一次性人工绘制):明城墙环 + 护城河、二环、主干道网、渭河水系、POI 圆点;viewBox `0 0 3000 2100`;景点坐标在 SVG 内直接读取 +3. **AI 素材层**(文生图 API 生成):纸张纹理底图、罗盘装饰、地图边框、区域淡彩;作为 Phaser 叠加 image 层 +4. **角色精灵表**:AI 生成 Q 版小人,4 方向(上下左右)× 3 帧(idle/迈步),帧格 48×64,导出单张 spritesheet PNG +5. **POI 标记**:图标 + 状态样式——未解锁为灰调轮廓,已解锁为亮色 + 解锁瞬间"盖章"缩放动效(手账贴纸语义) +6. **视觉基调**:羊皮纸 `#f5ecd7` 底、棕褐 `#8a6a42` 线条、暖橙 `#e67e22` 强调色;中文字体优先 `Kaiti SC`/手写体回退 + +--- + +## 7. 玩法系统细节 + +### 7.1 触发 + +- Phaser `physics.add.overlap(player, poiZone)` 检测 +- 弹窗关闭后设 `cooldown[spotId] = true`,玩家离开该 Zone 的 `overlap` 消失事件清除 +- 已解锁景点重复进入仍可重看(不重复计数) + +### 7.2 进度 + +- 解锁即 `game-state.unlock(id)` → `state:changed` → HUD 徽章 `n/total` + 标记亮色 +- 达到 `total` 时额外广播 `game:complete` → CompleteOverlay + +### 7.3 方向引导 + +- 每帧计算:玩家位置 → 最近未解锁景点向量 +- 渲染屏幕边缘箭头(贴边固定距离 + 轻微浮动动画 + 箭头根部显示目标景点名标签,不显示数字距离——viewBox 单位无真实米义) +- 触发条件补充:目标已在相机视野内时箭头淡出(避免遮住玩家要看的内容) +- 全解锁后移除 + +### 7.4 重置 + +- 封面页"重新开始" → 原生 `confirm` 确认 → `resetProgress()` → 重载页面 + +### 7.5 纪念页 + +- 手账风全屏页:已解锁景点照片九宫格(点击可放大重看)+ 景点数/旅行天数统计(天数口径 = spots 配置中出现的不重复 `date` 数量)+ "继续漫游"按钮 + +--- + +## 8. UI 设计规格(DOM 层) + +### 8.1 拍立得弹窗 PhotoModal + +- 结构:全屏半透明遮罩 + 居中拍立得卡(白边相纸:照片区 4:3,下边加宽放手写描述) +- 旋转:卡片 `-2.5°` 随机 ±2°,投影 `0 6px 18px rgba(0,0,0,.35)` +- 多照片:向右错位叠沓(每张偏移 12px 旋转 +2°),点击当前卡片翻至下一张(CSS transform 过渡 300ms) +- 描述:手写体(`Kaiti SC` 回退),右下角日期角标 +- 关闭:遮罩点击 / ESC / 空格(PC);关闭按钮常显(移动端单手区) +- 照片缺失降级:显示"照片待补"占位卡(兼作换图核对清单) + +### 8.2 封面页 TitleOverlay + +- 手账封面质感:纸张底纹 + 标题"西安旅行记忆" + 装饰(罗盘/胶带贴纸) +- 首次进入:单一"开始旅行"大按钮 +- 有存档:显示"继续旅行 (n/12)" + 次级"重新开始" +- 进入游戏时整页翻页/淡出过渡(600ms) + +### 8.3 HUD + +- 右上角进度徽章:`🗺️ 5/12`,解锁时数字跳动动效 +- 移动端安全区内渲染 + +### 8.4 竖屏提示 + +- 检测 `window.innerHeight > window.innerWidth` 且为触摸设备时全屏显示"请旋转手机"插画页;回到横屏自动消失 + +--- + +## 9. 移动端适配 + +| 项 | 方案 | +|---|---| +| 缩放 | `Phaser.Scale.FIT` + `autoCenter: CENTER_BOTH`,设计分辨率 **1600×900** | +| 屏幕方向 | Web 竖屏显示旋转提示页;M6 Capacitor 阶段锁横屏 | +| 摇杆 | `rexVirtualJoystick` 左下角,半径 60,仅触摸设备显示,安全区偏移 | +| 触控 | CSS `touch-action: none`、`user-select: none`、`viewport-fit=cover` + `env(safe-area-inset-*)` | +| 字号 | DOM UI 层 `clamp()` 响应式字号 | +| 调试 | `vite --host` 局域网真机调试 | + +--- + +## 10. 项目结构 + +``` +xian-travel-game/ +├── public/assets/ +│ ├── maps/ # map-base.svg(路网) map-texture.png(AI 纸纹) 等分层素材 +│ ├── photos/ # 占位 SVG → 真实照片(景点id_序号.jpg) +│ ├── sprites/ # player.png(Q版精灵表) +│ └── audio/ # 预留(本期不实现) +├── src/ +│ ├── main.js # Phaser 配置与启动(FIT/1600×900/插件注册) +│ ├── event-bus.js # 自写事件桥 on/once/off/emit +│ ├── game-state.js # localStorage 存档模块 +│ ├── data/spots.js # 景点配置(唯一内容源) +│ ├── scenes/ +│ │ ├── PreloadScene.js # 进度条 + 资源加载 + 失败降级 +│ │ └── MapScene.js # 地图/角色/摇杆/触发/引导箭头/标记状态 +│ ├── objects/ +│ │ ├── Player.js # 角色移动与动画(键盘+摇杆,斜向归一化) +│ │ └── PoiMarker.js # 景点标记(图标/状态样式/盖章动效) +│ └── ui/ # DOM UI 层 +│ ├── dom-layer.js # 层管理(挂载/互斥/过渡) +│ ├── TitleOverlay.js +│ ├── PhotoModal.js +│ ├── Hud.js +│ ├── CompleteOverlay.js +│ └── rotate-hint.js # 竖屏提示 +├── index.html # #game + #ui 容器 + 移动端 meta +├── vite.config.js # base './' / host:true / assetsInlineLimit:0 +└── package.json # phaser + phaser3-rex-plugins(Capacitor 依赖 M6 再装) +``` + +**编码规范**:每个文件顶部文件头注释(用途/职责/主要流程);函数级 Google 风格注释(中文);ESM 模块。 + +--- + +## 11. 错误处理 + +| 场景 | 处理 | +|---|---| +| 资源加载失败 | PreloadScene `load.on('loaderror')` 记录并以内联占位图降级,不中断 | +| localStorage 不可用 | game-state 内存降级;封面页隐藏"继续旅行" | +| 照片文件缺失 | PhotoModal"照片待补"占位卡 | +| resize / orientationchange | `scale.refresh()` + DOM 层重排 | +| 触发风暴 | 冷却机制(见 7.1) | + +--- + +## 12. 测试与验收标准 + +| # | 验收项 | 通过标准 | +|---|---|---| +| 1 | PC 移动 | WASD/方向键八方向,斜向速度归一化不超速 | +| 2 | 触发弹窗 | 进入景点弹拍立得;ESC/空格/点击遮罩/关闭按钮可关;无原地连弹 | +| 3 | 移动端 | 摇杆可移动;弹窗可关;竖屏出现旋转提示,回横屏恢复 | +| 4 | 进度持久化 | 刷新/重开浏览器进度保留;重新开始可用并清档 | +| 5 | 引导 | 边缘箭头指向最近未解锁景点;目标进入视野时箭头淡出;全解锁后消失 | +| 6 | 纪念页 | n/n 全解锁出现;九宫格可点开放大;继续漫游可回地图 | +| 7 | 占位降级 | 断照片/断资源不白屏,显示占位卡 | +| 8 | 性能 | 首屏 < 3s(WiFi);底图 < 1.5MB;单照片 < 300KB;60fps 漫游(中端手机) | + +验收方式:M1/M2 结束各跑一遍 PC + 真机清单;`npm run build` 后本地 preview 冒烟。 + +--- + +## 13. 交付路线(里程碑) + +| 阶段 | 内容 | 出口标准 | +|---|---|---| +| M1 可玩核心 | 项目脚手架 + 占位地图/角色 + 移动 + 触发 + 拍立得弹窗 | 验收 1/2/7 | +| M2 完整体验 | 封面页 + 存档/进度 + 引导箭头 + 纪念页 + 摇杆 + 竖屏提示 | 验收 3/4/5/6 | +| M3 手账美化 | AI 纸纹/罗盘素材 + Q 版精灵表 + 盖章动效 + 封面精化 | 视觉走查 | +| M4 真实内容 | 照片替换(附《照片替换指南》)+ 坐标微调 | 验收 7/8 | +| M5 Web 发布 | build + Vercel/GitHub Pages(`base: './'`) | 手机打开链接可玩 | +| M6 APK(后置) | 安装 Capacitor 依赖 + init + 打包(锁横屏) | 真机安装可玩 | + +> M1–M5 为本期承诺范围;M6 在 Web 版验收后单独启动。 + +--- + +## 14. 照片替换指南(后期运营,随 M4 交付) + +1. **命名规范**:`public/assets/photos/<景点id>_<两位序号>.jpg`(如 `dayanta_01.jpg`) +2. **压缩规范**:宽度 ≤ 1200px,单张 ≤ 300KB,优先 WebP(文件名可保留 .jpg 后缀或统一改并同步 spots.js) +3. **配置更新**:在 `spots.js` 对应景点的 `photos[]` 增删条目、写 caption;`story`/`date` 按手账文案填写 +4. **自检**:跑一遍游戏,缺失照片会显示"照片待补"占位卡,逐个补齐即可 + +--- + +## 15. 与原 overview.md 的差异说明 + +| 原方案 | 细化后 | 原因 | +|---|---|---| +| PhotoScene 独立场景 + scene.pause/launch | DOM 层 PhotoModal + 输入禁用 | 拍立得/中文排版/动画是 CSS 强项,Phaser UI 弱项 | +| 三场景 | 两场景(Preload/Map) | 弹窗职责外移,简化状态机 | +| 图片地图 + 手动取坐标 | SVG 路网 + viewBox 坐标 | 位置准确、可分层叠加 AI 素材、坐标在源文件中直接可读 | +| 1280×720 | 1600×900 | 地图比例 3000×2100 匹配 10:7,横屏视野更舒展 | +| 无开场/进度/引导/纪念页 | 封面页 + 进度徽章 + 边缘箭头 + 纪念页 | 需求澄清新增的游戏化决策 | +| 一次性全部依赖安装 | Capacitor 依赖 M6 再装 | Web 优先交付,减少前期装机量 | diff --git a/index.html b/index.html new file mode 100644 index 0000000..c37f6c2 --- /dev/null +++ b/index.html @@ -0,0 +1,29 @@ + + + + + + + 西安旅行记忆 + + + + +
+ + +
+ + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9cebd37 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1248 @@ +{ + "name": "xian-travel-game", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xian-travel-game", + "version": "0.1.0", + "dependencies": { + "phaser": "^3.80.0", + "phaser3-rex-plugins": "^1.80.0" + }, + "devDependencies": { + "vite": "^5.4.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/graphology": { + "version": "0.25.4", + "resolved": "https://registry.npmmirror.com/graphology/-/graphology-0.25.4.tgz", + "integrity": "sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "obliterator": "^2.0.2" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmmirror.com/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/i18next": { + "version": "22.5.1", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-22.5.1.tgz", + "integrity": "sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.6" + } + }, + "node_modules/i18next-http-backend": { + "version": "2.7.3", + "resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-2.7.3.tgz", + "integrity": "sha512-FgZxrXdRA5u44xfYsJlEBL4/KH3f2IluBpgV/7riW0YW2VEyM8FzVt2XHAOi6id0Ppj7vZvCZVpp5LrGXnc8Ig==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmmirror.com/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "license": "MIT" + }, + "node_modules/phaser": { + "version": "3.90.0", + "resolved": "https://registry.npmmirror.com/phaser/-/phaser-3.90.0.tgz", + "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/phaser3-rex-plugins": { + "version": "1.80.20", + "resolved": "https://registry.npmmirror.com/phaser3-rex-plugins/-/phaser3-rex-plugins-1.80.20.tgz", + "integrity": "sha512-XZLUy74r1jUaRWpGplRhhA+wEeOvnNb0qlhlyMtwdwZwIlCA7C7WtgvynOB/PyVrzS09cUBwEFyG+A3P5fL+Uw==", + "license": "MIT", + "dependencies": { + "dagre": "^0.8.5", + "eventemitter3": "^3.1.2", + "graphology": "^0.25.4", + "i18next": "^22.5.1", + "i18next-http-backend": "^2.5.2", + "js-yaml": "^4.1.0", + "mustache": "^4.2.0", + "papaparse": "^5.4.1", + "webfontloader": "^1.6.28" + } + }, + "node_modules/phaser3-rex-plugins/node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.64.7", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webfontloader": { + "version": "1.6.28", + "resolved": "https://registry.npmmirror.com/webfontloader/-/webfontloader-1.6.28.tgz", + "integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d3d65f --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "xian-travel-game", + "private": true, + "version": "0.1.0", + "description": "西安旅行记忆互动地图游戏 —— Phaser 地图层 + DOM UI 层混合架构", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "phaser": "^3.80.0", + "phaser3-rex-plugins": "^1.80.0" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/public/assets/maps/map-base.svg b/public/assets/maps/map-base.svg new file mode 100644 index 0000000..f945ba2 --- /dev/null +++ b/public/assets/maps/map-base.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 大明宫遗址公园 + + + 兴庆宫公园 + + + + + + 渭 河 + + + + 灞河 + + + + 浐河 + + + + 沣河 + + + + 曲江池 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 明城墙 + + + + 临 潼 + 曲江新区 + 高新区 + 灞桥 + + + + + + + + + + 北 + + + + + 西安 · 旅行手账地图 + XI'AN TRAVEL JOURNAL MAP + + + + + + —— 走进景点,翻开你的旅行手账 —— + + + + + + + + + + + + diff --git a/public/assets/photos/bingmayong_01.svg b/public/assets/photos/bingmayong_01.svg new file mode 100644 index 0000000..fabca3c --- /dev/null +++ b/public/assets/photos/bingmayong_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + ⚔️ + 一号坑的军阵全景 + · 占位照片 · + diff --git a/public/assets/photos/bingmayong_02.svg b/public/assets/photos/bingmayong_02.svg new file mode 100644 index 0000000..1908eec --- /dev/null +++ b/public/assets/photos/bingmayong_02.svg @@ -0,0 +1,16 @@ + + + + + + + + + + ⚔️ + 跪射俑的侧脸 + · 占位照片 · + diff --git a/public/assets/photos/buyecheng_01.svg b/public/assets/photos/buyecheng_01.svg new file mode 100644 index 0000000..9733cf9 --- /dev/null +++ b/public/assets/photos/buyecheng_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🏮 + 华灯初上的步行街 + · 占位照片 · + diff --git a/public/assets/photos/buyecheng_02.svg b/public/assets/photos/buyecheng_02.svg new file mode 100644 index 0000000..71dcd97 --- /dev/null +++ b/public/assets/photos/buyecheng_02.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🏮 + 不倒翁小姐姐 + · 占位照片 · + diff --git a/public/assets/photos/chengqiang_01.svg b/public/assets/photos/chengqiang_01.svg new file mode 100644 index 0000000..08169fd --- /dev/null +++ b/public/assets/photos/chengqiang_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🧱 + 城墙上骑行的傍晚 + · 占位照片 · + diff --git a/public/assets/photos/daminggong_01.svg b/public/assets/photos/daminggong_01.svg new file mode 100644 index 0000000..d6b2bb2 --- /dev/null +++ b/public/assets/photos/daminggong_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🏛️ + 含元殿的巨大夯土台 + · 占位照片 · + diff --git a/public/assets/photos/dayanta_01.svg b/public/assets/photos/dayanta_01.svg new file mode 100644 index 0000000..abe3012 --- /dev/null +++ b/public/assets/photos/dayanta_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🗼 + 夕阳下的大雁塔 + · 占位照片 · + diff --git a/public/assets/photos/dayanta_02.svg b/public/assets/photos/dayanta_02.svg new file mode 100644 index 0000000..d151439 --- /dev/null +++ b/public/assets/photos/dayanta_02.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🗼 + 音乐喷泉开场的瞬间 + · 占位照片 · + diff --git a/public/assets/photos/huaqingchi_01.svg b/public/assets/photos/huaqingchi_01.svg new file mode 100644 index 0000000..117171b --- /dev/null +++ b/public/assets/photos/huaqingchi_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + ♨️ + 骊山脚下的温泉古苑 + · 占位照片 · + diff --git a/public/assets/photos/huiminjie_01.svg b/public/assets/photos/huiminjie_01.svg new file mode 100644 index 0000000..2f0dfc7 --- /dev/null +++ b/public/assets/photos/huiminjie_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🥟 + 滋滋冒油的烤肉串 + · 占位照片 · + diff --git a/public/assets/photos/huiminjie_02.svg b/public/assets/photos/huiminjie_02.svg new file mode 100644 index 0000000..591cc2c --- /dev/null +++ b/public/assets/photos/huiminjie_02.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🥟 + 排长队的甑糕摊 + · 占位照片 · + diff --git a/public/assets/photos/shanbwy_01.svg b/public/assets/photos/shanbwy_01.svg new file mode 100644 index 0000000..6e27758 --- /dev/null +++ b/public/assets/photos/shanbwy_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🏺 + 排队两小时的入口 + · 占位照片 · + diff --git a/public/assets/photos/shanbwy_02.svg b/public/assets/photos/shanbwy_02.svg new file mode 100644 index 0000000..6b74611 --- /dev/null +++ b/public/assets/photos/shanbwy_02.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🏺 + 唐代鎏金舞马衔杯银壶 + · 占位照片 · + diff --git a/public/assets/photos/xiaoyanta_01.svg b/public/assets/photos/xiaoyanta_01.svg new file mode 100644 index 0000000..cc458f9 --- /dev/null +++ b/public/assets/photos/xiaoyanta_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🛕 + 晨钟里的古塔剪影 + · 占位照片 · + diff --git a/public/assets/photos/zhonglou_01.svg b/public/assets/photos/zhonglou_01.svg new file mode 100644 index 0000000..772f423 --- /dev/null +++ b/public/assets/photos/zhonglou_01.svg @@ -0,0 +1,16 @@ + + + + + + + + + + 🏯 + 夜晚的钟楼灯火辉煌 + · 占位照片 · + diff --git a/public/assets/textures/paper.jpg b/public/assets/textures/paper.jpg new file mode 100644 index 0000000..e7b4c3d Binary files /dev/null and b/public/assets/textures/paper.jpg differ diff --git a/scripts/gen-placeholder-photos.mjs b/scripts/gen-placeholder-photos.mjs new file mode 100644 index 0000000..4c29413 --- /dev/null +++ b/scripts/gen-placeholder-photos.mjs @@ -0,0 +1,116 @@ +/** + * @fileoverview 占位照片生成脚本。 + * + * 文件用途: + * - 依据 src/data/spots.js 的照片清单,批量生成 4:3 手账风占位 SVG, + * 供照片未就绪期的开发与演示使用。 + * + * 核心职责: + * 1. 读取景点配置,为每张照片生成 `public/assets/photos/_<序号>.svg`; + * 2. 占位视觉:暖色渐变 + 景点图标 + 名称与 caption 水印 + 虚线相框; + * 3. 文件已存在时跳过(--force 强制重生成)。 + * + * 主要流程: + * node scripts/gen-placeholder-photos.mjs [--force] → 遍历 spots → + * 写出 SVG → 控制台汇总生成结果。 + * + * 用法: + * node scripts/gen-placeholder-photos.mjs # 仅生成缺失文件 + * node scripts/gen-placeholder-photos.mjs --force # 强制重新生成全部 + */ + +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { SPOTS } from '../src/data/spots.js'; + +/** 脚本所在目录。 */ +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +/** 项目根目录。 */ +const PROJECT_ROOT = resolve(SCRIPT_DIR, '..'); +/** 照片输出目录。 */ +const OUT_DIR = resolve(PROJECT_ROOT, 'public/assets/photos'); + +/** 占位卡宽度(像素)。 */ +const W = 1200; +/** 占位卡高度(像素,4:3)。 */ +const H = 900; + +/** + * 按景点 id 生成稳定的色相(0-360)。 + * + * @param {string} id 景点 id。 + * @returns {number} 色相角度。 + */ +function hueOf(id) { + let hash = 0; + for (const ch of id) { + hash = (hash * 31 + ch.charCodeAt(0)) % 360; + } + return hash; +} + +/** + * 生成单张占位照片 SVG 内容。 + * + * @param {Object} spot 景点配置。 + * @param {Object} photo 照片配置(含 caption)。 + * @returns {string} SVG 文本。 + */ +function buildSvg(spot, photo) { + const hue = hueOf(spot.id); + const icon = spot.icon; + const escapedCaption = photo.caption + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + return ` + + + + + + + + + ${icon} + ${escapedCaption} + · 占位照片 · + +`; +} + +/** + * 脚本入口:遍历景点生成占位照片。 + * + * @returns {Promise} 完成后输出汇总。 + */ +async function main() { + const force = process.argv.includes('--force'); + mkdirSync(OUT_DIR, { recursive: true }); + + let created = 0; + let skipped = 0; + for (const spot of SPOTS) { + spot.photos.forEach((photo, index) => { + const fileName = photo.src.split('/').pop(); + const outPath = resolve(OUT_DIR, fileName); + if (!force && existsSync(outPath)) { + skipped += 1; + return; + } + writeFileSync(outPath, buildSvg(spot, photo), 'utf8'); + created += 1; + console.log(`[photos] 生成 ${fileName}`); + }); + } + console.log(`[photos] 完成:新建 ${created} 张,跳过已存在 ${skipped} 张`); +} + +main().catch((err) => { + console.error('[photos] 生成失败:', err); + process.exit(1); +}); diff --git a/src/data/spots.js b/src/data/spots.js new file mode 100644 index 0000000..2a77b82 --- /dev/null +++ b/src/data/spots.js @@ -0,0 +1,156 @@ +/** + * @fileoverview 景点配置(唯一内容源)。 + * + * 文件用途: + * - 声明全部景点的坐标、照片、文案等静态配置,供 Phaser 层与 DOM UI 层共同消费。 + * + * 核心职责: + * 1. 定义 10 个西安景点的触发坐标(与 public/assets/maps/map-base.svg 的 + * viewBox 坐标系一致:0 0 3000 2100); + * 2. 声明每个景点的占位照片路径与手账文案(story / date / caption); + * 3. 替换真实照片时只需更新本文件与照片文件,不改任何代码逻辑。 + * + * 主要流程: + * PreloadScene 读取 photos[].src 加载图片 → MapScene 按 x/y/radius 创建 + * 触发区与标记 → PhotoModal 读取 name/story/date 渲染拍立得卡片。 + */ + +/** + * 景点清单。坐标单位 = SVG 底图 viewBox 单位(约 8.3 米/单位)。 + * + * @type {Array} 景点配置数组。 + */ +export const SPOTS = [ + { + id: 'zhonglou', + name: '钟楼', + x: 1300, + y: 1150, + radius: 60, + icon: '🏯', + photos: [{ src: 'assets/photos/zhonglou_01.svg', caption: '夜晚的钟楼灯火辉煌' }], + story: '站在钟楼下,车流绕着转盘缓缓流淌,灯光把飞檐勾成金色。', + date: '10月1日', + }, + { + id: 'huiminjie', + name: '回民街', + x: 1140, + y: 1060, + radius: 60, + icon: '🥟', + photos: [ + { src: 'assets/photos/huiminjie_01.svg', caption: '滋滋冒油的烤肉串' }, + { src: 'assets/photos/huiminjie_02.svg', caption: '排长队的甑糕摊' }, + ], + story: '从街头吃到街尾:泡馍、烤肉、甑糕、石榴汁,一样都没落下。', + date: '10月1日', + }, + { + id: 'chengqiang', + name: '明城墙 · 永宁门', + x: 1300, + y: 1370, + radius: 65, + icon: '🧱', + photos: [{ src: 'assets/photos/chengqiang_01.svg', caption: '城墙上骑行的傍晚' }], + story: '租了辆自行车在城墙上绕圈,晚风把护城河的波光吹进了回忆里。', + date: '10月2日', + }, + { + id: 'daminggong', + name: '大明宫遗址', + x: 1330, + y: 780, + radius: 65, + icon: '🏛️', + photos: [{ src: 'assets/photos/daminggong_01.svg', caption: '含元殿的巨大夯土台' }], + story: '站在含元殿遗址上,想象千年前万国来朝的场面,只剩下风声。', + date: '10月2日', + }, + { + id: 'xiaoyanta', + name: '小雁塔', + x: 960, + y: 1410, + radius: 60, + icon: '🛕', + photos: [{ src: 'assets/photos/xiaoyanta_01.svg', caption: '晨钟里的古塔剪影' }], + story: '比大雁塔安静太多,塔影落在草地上,时间好像慢了半拍。', + date: '10月3日', + }, + { + id: 'shanbwy', + name: '陕西历史博物馆', + x: 1680, + y: 1560, + radius: 65, + icon: '🏺', + photos: [ + { src: 'assets/photos/shanbwy_01.svg', caption: '排队两小时的入口' }, + { src: 'assets/photos/shanbwy_02.svg', caption: '唐代鎏金舞马衔杯银壶' }, + ], + story: '国宝多到看不过来,讲解器里传来一千三百年的回声。', + date: '10月3日', + }, + { + id: 'dayanta', + name: '大雁塔', + x: 1900, + y: 1650, + radius: 70, + icon: '🗼', + photos: [ + { src: 'assets/photos/dayanta_01.svg', caption: '夕阳下的大雁塔' }, + { src: 'assets/photos/dayanta_02.svg', caption: '音乐喷泉开场的瞬间' }, + ], + story: '国庆第 3 天,我们在塔下等灯光亮起,喷泉和人群一起欢呼。', + date: '10月3日', + }, + { + id: 'buyecheng', + name: '大唐不夜城', + x: 2060, + y: 1820, + radius: 70, + icon: '🏮', + photos: [ + { src: 'assets/photos/buyecheng_01.svg', caption: '华灯初上的步行街' }, + { src: 'assets/photos/buyecheng_02.svg', caption: '不倒翁小姐姐' }, + ], + story: '整条街都是灯火和汉服,恍惚间不知道今夕是何年。', + date: '10月4日', + }, + { + id: 'huaqingchi', + name: '华清宫', + x: 2560, + y: 470, + radius: 65, + icon: '♨️', + photos: [{ src: 'assets/photos/huaqingchi_01.svg', caption: '骊山脚下的温泉古苑' }], + story: '杨贵妃泡过的温泉早已冷却,骊山的缆车却把整座西安看了一遍。', + date: '10月5日', + }, + { + id: 'bingmayong', + name: '兵马俑', + x: 2820, + y: 240, + radius: 70, + icon: '⚔️', + photos: [ + { src: 'assets/photos/bingmayong_01.svg', caption: '一号坑的军阵全景' }, + { src: 'assets/photos/bingmayong_02.svg', caption: '跪射俑的侧脸' }, + ], + story: '世界第八大奇迹,震撼到说不出话,千人千面是真的。', + date: '10月5日', + }, +]; + +/** + * 玩家出生点(城墙内西南角附近,远离首个引导目标)。 + * + * @type {{x: number, y: number}} 出生坐标。 + */ +export const SPAWN = { x: 1120, y: 1240 }; diff --git a/src/event-bus.js b/src/event-bus.js new file mode 100644 index 0000000..d93b486 --- /dev/null +++ b/src/event-bus.js @@ -0,0 +1,97 @@ +/** + * @fileoverview 跨层事件桥(EventBus)。 + * + * 文件用途: + * - 提供 Phaser 游戏层与 DOM UI 层之间的解耦通信通道。 + * + * 核心职责: + * 1. 极简发布订阅实现(on / once / off / emit); + * 2. 约定事件协议(见 docs/spec.md 3.2 节): + * - poi:reached Phaser → UI 玩家进入景点触发区,请求弹窗 + * - ui:closed UI → Phaser 弹窗/封面关闭,恢复游戏输入 + * - state:changed 存档 → 两层 解锁进度变化,刷新 HUD 与地图标记 + * - game:complete 存档 → UI 全部解锁,弹出纪念页 + * + * 主要流程: + * main.js 引入单例 → 各模块 import 同一实例 → 通过事件名通信。 + */ + +/** + * 创建事件总线实例。 + * + * @returns {{on: Function, once: Function, off: Function, emit: Function}} 事件总线 API。 + */ +function createEventBus() { + /** @type {Map>} 事件名 → 监听器集合。 */ + const listeners = new Map(); + + return { + /** + * 注册事件监听器。 + * + * @param {string} event 事件名。 + * @param {Function} handler 监听函数。 + * @returns {Function} 取消注册函数。 + */ + on(event, handler) { + if (!listeners.has(event)) { + listeners.set(event, new Set()); + } + listeners.get(event).add(handler); + return () => this.off(event, handler); + }, + + /** + * 注册一次性监听器(触发一次后自动移除)。 + * + * @param {string} event 事件名。 + * @param {Function} handler 监听函数。 + */ + once(event, handler) { + const wrapped = (...args) => { + this.off(event, wrapped); + handler(...args); + }; + this.on(event, wrapped); + }, + + /** + * 移除事件监听器。 + * + * @param {string} event 事件名。 + * @param {Function} handler 要移除的监听函数。 + */ + off(event, handler) { + const set = listeners.get(event); + if (set) { + set.delete(handler); + if (set.size === 0) { + listeners.delete(event); + } + } + }, + + /** + * 发布事件(同步调用所有监听器)。 + * + * @param {string} event 事件名。 + * @param {...any} args 事件载荷。 + */ + emit(event, ...args) { + const set = listeners.get(event); + if (set) { + [...set].forEach((handler) => { + try { + handler(...args); + } catch (err) { + // 单个监听器异常不阻断其他监听器与游戏主流程。 + console.error(`[event-bus] "${event}" 监听器执行出错:`, err); + } + }); + } + }, + }; +} + +/** 全局共享事件总线单例。 */ +export const eventBus = createEventBus(); diff --git a/src/game-state.js b/src/game-state.js new file mode 100644 index 0000000..a338d72 --- /dev/null +++ b/src/game-state.js @@ -0,0 +1,175 @@ +/** + * @fileoverview 旅行进度存档模块(game-state)。 + * + * 文件用途: + * - 管理已解锁景点的持久化状态,独立于 Phaser 层与 DOM UI 层之外。 + * + * 核心职责: + * 1. localStorage 读写进度(key: xian-travel-progress); + * 2. 提供 loadProgress / unlock / resetProgress / isUnlocked / getSnapshot API; + * 3. 隐私模式降级:localStorage 不可用时回退内存 Map,会话内有效; + * 4. 进度变化时通过 eventBus 广播 state:changed / game:complete。 + * + * 主要流程: + * 启动时 loadProgress → 玩家解锁景点时 unlock → 写档并广播 → + * 达到总数时广播 game:complete → 封面页"重新开始"调用 resetProgress。 + */ + +import { eventBus } from './event-bus.js'; +import { SPOTS } from './data/spots.js'; + +/** localStorage 存档键名。 */ +const STORAGE_KEY = 'xian-travel-progress'; + +/** 内存降级存储(localStorage 不可用时使用)。 */ +const memoryStore = { unlocked: [] }; + +/** + * 检测 localStorage 是否可用(隐私模式 / 被禁用时返回 false)。 + * + * @returns {boolean} 可用返回 true。 + */ +function isStorageAvailable() { + try { + const probe = '__xian_travel_probe__'; + window.localStorage.setItem(probe, '1'); + window.localStorage.removeItem(probe); + return true; + } catch { + return false; + } +} + +/** @type {boolean} 是否使用 localStorage 持久化。 */ +const persistent = isStorageAvailable(); + +/** @type {Set} 当前已解锁景点 id 集合。 */ +const unlockedSet = new Set(); + +/** @type {boolean} 模块是否已初始化(避免重复读取)。 */ +let initialized = false; + +/** + * 从存储读取存档并初始化解锁集合(仅首次调用生效)。 + * + * @returns {void} + */ +function ensureLoaded() { + if (initialized) { + return; + } + initialized = true; + try { + let raw = null; + if (persistent) { + raw = window.localStorage.getItem(STORAGE_KEY); + } + const saved = raw ? JSON.parse(raw) : memoryStore; + if (saved && Array.isArray(saved.unlocked)) { + // 只保留 spots.js 中仍存在的 id,容错配置变更后的脏数据。 + saved.unlocked + .filter((id) => SPOTS.some((spot) => spot.id === id)) + .forEach((id) => unlockedSet.add(id)); + } + } catch { + // 存档损坏时视为无存档,不影响游戏启动。 + console.warn('[game-state] 存档解析失败,按无存档处理'); + } +} + +/** + * 将当前解锁集合写回存储。 + * + * @returns {void} + */ +function persist() { + const payload = JSON.stringify({ + unlocked: [...unlockedSet], + updatedAt: Date.now(), + }); + if (persistent) { + try { + window.localStorage.setItem(STORAGE_KEY, payload); + } catch { + // 写入失败(配额满等)时静默降级,游戏仍可运行。 + console.warn('[game-state] 存档写入失败'); + } + } else { + memoryStore.unlocked = [...unlockedSet]; + } +} + +/** + * 读取当前进度(幂等,首次调用时从存储加载)。 + * + * @returns {{unlocked: string[], total: number, updatedAt: number}} 进度快照。 + */ +export function loadProgress() { + ensureLoaded(); + return getSnapshot(); +} + +/** + * 解锁指定景点:写档并广播进度变化。 + * + * @param {string} spotId 景点唯一 id。 + * @returns {boolean} 本次调用是否真正解锁(已解锁时返回 false)。 + */ +export function unlock(spotId) { + ensureLoaded(); + if (unlockedSet.has(spotId)) { + return false; + } + unlockedSet.add(spotId); + persist(); + eventBus.emit('state:changed', getSnapshot()); + if (unlockedSet.size >= SPOTS.length) { + eventBus.emit('game:complete', getSnapshot()); + } + return true; +} + +/** + * 查询景点是否已解锁。 + * + * @param {string} spotId 景点唯一 id。 + * @returns {boolean} 已解锁返回 true。 + */ +export function isUnlocked(spotId) { + ensureLoaded(); + return unlockedSet.has(spotId); +} + +/** + * 获取进度快照。 + * + * @returns {{unlocked: string[], total: number, updatedAt: number}} 快照数据。 + */ +export function getSnapshot() { + return { + unlocked: [...unlockedSet], + total: SPOTS.length, + updatedAt: Date.now(), + }; +} + +/** + * 清空存档并广播进度变化(封面页"重新开始"使用)。 + * + * @returns {void} + */ +export function resetProgress() { + unlockedSet.clear(); + persist(); + eventBus.emit('state:changed', getSnapshot()); +} + +/** + * 是否有历史存档(决定封面页显示"继续旅行"还是"开始旅行")。 + * + * @returns {boolean} 存在未空存档返回 true。 + */ +export function hasSave() { + ensureLoaded(); + return persistent ? window.localStorage.getItem(STORAGE_KEY) !== null : unlockedSet.size > 0; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..9f13256 --- /dev/null +++ b/src/main.js @@ -0,0 +1,54 @@ +/** + * @fileoverview 游戏入口与全局配置。 + * + * 文件用途: + * - 创建 Phaser.Game 实例并装配虚拟摇杆插件,随后启动 DOM UI 层。 + * + * 核心职责: + * 1. Phaser 全局配置:1600×900 设计分辨率、FIT 缩放、arcade 物理; + * 2. 注册 rexVirtualJoystick 场景插件(移动端摇杆); + * 3. 场景清单:PreloadScene → MapScene; + * 4. 启动 DOM UI 层(封面页 / 弹窗 / HUD / 纪念页 / 竖屏提示)。 + * + * 主要流程: + * 页面加载 → new Phaser.Game(config) → 同时初始化 TitleOverlay(封面 + * 覆盖在 Canvas 之上,点击"开始旅行"后通过 ui:closed 放行游戏输入)。 + */ + +import Phaser from 'phaser'; +import VirtualJoystickPlugin from 'phaser3-rex-plugins/plugins/virtualjoystick-plugin.js'; + +import PreloadScene from './scenes/PreloadScene.js'; +import MapScene from './scenes/MapScene.js'; +import { mountUi } from './ui/dom-layer.js'; + +/** Phaser 全局配置对象。 */ +const config = { + type: Phaser.AUTO, + parent: 'game', + backgroundColor: '#f5ecd7', + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + width: 1600, + height: 900, + }, + physics: { + default: 'arcade', + arcade: { gravity: { y: 0 }, debug: false }, + }, + plugins: { + scene: [ + { + key: 'rexVirtualJoystick', + plugin: VirtualJoystickPlugin, + mapping: 'rexVirtualJoystick', + }, + ], + }, + scene: [PreloadScene, MapScene], +}; + +new Phaser.Game(config); + +mountUi(); diff --git a/src/objects/GuideArrow.js b/src/objects/GuideArrow.js new file mode 100644 index 0000000..753c628 --- /dev/null +++ b/src/objects/GuideArrow.js @@ -0,0 +1,120 @@ +/** + * @fileoverview 方向引导箭头(GuideArrow)。 + * + * 文件用途: + * - 在屏幕边缘渲染指向"最近未解锁景点"的浮动箭头,防止玩家在大地图上迷路。 + * + * 核心职责: + * 1. 每帧根据玩家 → 目标的方向角计算箭头贴边位置与旋转角; + * 2. 目标进入相机视野时淡出(不遮挡玩家正要看的内容),离开视野再淡入; + * 3. 附带正弦浮动动画与目标景点名标签(不显示数字距离)。 + * + * 主要流程: + * MapScene 创建 GuideArrow → update() 每帧传入玩家与目标 → + * 全部解锁后 MapScene 调用 hide() 移除。 + */ + +import Phaser from 'phaser'; + +/** 箭头距屏幕边缘的安全内缩(避开 HUD 与摇杆)。 */ +const EDGE_MARGIN = 110; +/** 浮动动画幅度(像素)。 */ +const FLOAT_AMPLITUDE = 7; + +/** + * 屏幕边缘引导箭头(scrollFactor 固定为 0,不随相机移动)。 + * + * @extends Phaser.GameObjects.Container + */ +export default class GuideArrow extends Phaser.GameObjects.Container { + /** + * 创建引导箭头。 + * + * @param {Phaser.Scene} scene 所属场景。 + */ + constructor(scene) { + super(scene, 0, 0); + scene.add.existing(this); + this.setScrollFactor(0).setDepth(50); + + // 箭头图形:橙色三角 + 白描边(指向 +x 方向,随 rotation 旋转)。 + /** @type {Phaser.GameObjects.Graphics} */ + this.arrow = scene.add.graphics(); + this.arrow.fillStyle(0xe67e22, 1); + this.arrow.lineStyle(3, 0xfff8ec, 1); + this.arrow.fillTriangle(34, 0, -10, -17, -10, 17); + this.arrow.strokeTriangle(34, 0, -10, -17, -10, 17); + this.arrow.strokePath(); + + // 目标景点名标签(跟随在箭头尾部)。 + /** @type {Phaser.GameObjects.Text} */ + this.label = scene.add + .text(-26, 0, '', { + fontFamily: '"Kaiti SC", "KaiTi", serif', + fontSize: '18px', + color: '#6b4e2e', + backgroundColor: '#f5ecd7e6', + padding: { x: 8, y: 3 }, + }) + .setOrigin(1, 0.5); + + this.add([this.arrow, this.label]); + this.setVisible(false); + } + + /** + * 每帧更新箭头位置、朝向与可见性。 + * + * @param {Phaser.Physics.Arcade.Sprite} player 玩家精灵。 + * @param {Object|null} target 最近未解锁景点(null 表示全部解锁,箭头隐藏)。 + * @param {number} time 场景时间(毫秒,用于浮动动画)。 + * @returns {void} + */ + update(player, target, time) { + if (!target) { + this.setVisible(false); + return; + } + const camera = this.scene.cameras.main; + const view = camera.worldView; + + // 目标已在相机视野内:淡出等待。 + const inView = view.contains(target.x, target.y); + const targetAlpha = inView ? 0 : 1; + this.setAlpha(Phaser.Math.Linear(this.alpha, targetAlpha, 0.12)); + this.setVisible(this.alpha > 0.05); + if (!this.visible) { + return; + } + + // 玩家 → 目标的世界方向。 + const dx = target.x - player.x; + const dy = target.y - player.y; + const angle = Math.atan2(dy, dx); + + // 屏幕中心沿方向推出,并 clamp 到屏幕边缘内缩矩形。 + const cx = camera.width / 2; + const cy = camera.height / 2; + const halfW = cx - EDGE_MARGIN; + const halfH = cy - EDGE_MARGIN - 20; + // 方向在屏幕半平面的延伸系数(取 x/y 分量中先触及边界者)。 + const tx = Math.abs(Math.cos(angle)) > 1e-6 ? halfW / Math.abs(Math.cos(angle)) : Infinity; + const ty = Math.abs(Math.sin(angle)) > 1e-6 ? halfH / Math.abs(Math.sin(angle)) : Infinity; + const extend = Math.min(tx, ty, Math.hypot(halfW, halfH)); + + // 正弦浮动:沿法线方向轻摆。 + const floatOffset = Math.sin(time / 380) * FLOAT_AMPLITUDE; + this.setPosition( + cx + Math.cos(angle) * extend - Math.sin(angle) * floatOffset, + cy + Math.sin(angle) * extend + Math.cos(angle) * floatOffset + ); + this.arrow.setRotation(angle); + this.label.setText(target.name); + // 标签始终水平可读:独立于箭头旋转,向屏幕中心回退放置。 + this.label.setRotation(0); + this.label.setPosition( + -Math.cos(angle) * 96 - Math.sin(angle) * 4, + -Math.sin(angle) * 96 + Math.cos(angle) * 4 + ); + } +} diff --git a/src/objects/Player.js b/src/objects/Player.js new file mode 100644 index 0000000..b8e2800 --- /dev/null +++ b/src/objects/Player.js @@ -0,0 +1,296 @@ +/** + * @fileoverview 玩家角色类(Player)。 + * + * 文件用途: + * - 定义可在手账地图上漫游的 Q 版贴纸小人(占位形态,AI 精灵表就绪后可整体替换)。 + * + * 核心职责: + * 1. 用离屏 Canvas 程序绘制 4 方向 × 2 帧的贴纸小人精灵表(48×64 帧格); + * 2. 管理物理移动:键盘(WASD / 方向键)与虚拟摇杆输入,斜向速度归一化; + * 3. 按移动方向切换行走动画,静止时停在当前方向的 idle 帧。 + * + * 主要流程: + * MapScene 创建 Player → 每帧调用 update() → 汇总输入向量 → + * 归一化乘以速度 → setVelocity + 播放对应方向动画。 + */ + +import Phaser from 'phaser'; + +/** 帧格宽度(像素)。 */ +const FRAME_W = 48; +/** 帧格高度(像素)。 */ +const FRAME_H = 64; +/** 每方向帧数。 */ +const FRAMES_PER_DIR = 2; +/** 移动速度(像素/秒)。 */ +const MOVE_SPEED = 260; + +/** + * 生成贴纸小人精灵表纹理并注册到 Phaser 纹理管理器。 + * + * 帧布局(8 帧横排):[down×2, up×2, left×2, right×2]。 + * + * @param {Phaser.Scene} scene 目标场景(用于注册纹理)。 + * @returns {void} + */ +function buildPlayerTexture(scene) { + const dirs = ['down', 'up', 'left', 'right']; + const totalFrames = dirs.length * FRAMES_PER_DIR; + const canvasTexture = scene.textures.createCanvas( + 'player', + totalFrames * FRAME_W, + FRAME_H + ); + const ctx = canvasTexture.getContext(); + + dirs.forEach((dir, dirIndex) => { + for (let frame = 0; frame < FRAMES_PER_DIR; frame += 1) { + const ox = (dirIndex * FRAMES_PER_DIR + frame) * FRAME_W; + drawStickFigure(ctx, ox, 0, dir, frame, dir === 'right'); + // 手动注册帧索引,使 sprite 可按 frame 序号取帧。 + canvasTexture.add(dirIndex * FRAMES_PER_DIR + frame, 0, ox, 0, FRAME_W, FRAME_H); + } + }); + canvasTexture.refresh(); +} + +/** + * 在指定偏移处绘制单帧贴纸小人。 + * + * @param {CanvasRenderingContext2D} ctx 画布上下文。 + * @param {number} ox 帧左上角 x 偏移。 + * @param {number} oy 帧左上角 y 偏移。 + * @param {string} dir 朝向(down / up / left / right)。 + * @param {number} frame 行走帧序号(0 静止姿态,1 迈步姿态)。 + * @param {boolean} mirror 是否水平镜像(right 复用 left 绘制)。 + * @returns {void} + */ +function drawStickFigure(ctx, ox, oy, dir, frame, mirror) { + ctx.save(); + ctx.translate(ox, oy); + if (mirror) { + ctx.translate(FRAME_W, 0); + ctx.scale(-1, 1); + } + + const walkLift = frame === 1 ? 2 : 0; // 迈步帧整体上抬,产生弹跳感。 + const cy = 2 + walkLift; + + // 脚下阴影。 + ctx.beginPath(); + ctx.ellipse(24, 58, 13, 4.5, 0, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(80, 60, 40, 0.18)'; + ctx.fill(); + + // 腿:静止并拢,迈步时前后错开。 + ctx.fillStyle = '#6b4e2e'; + if (frame === 0) { + ctx.fillRect(18, 50 - walkLift, 4.5, 7); + ctx.fillRect(26, 50 - walkLift, 4.5, 7); + } else { + ctx.fillRect(15, 50 - walkLift, 4.5, 9); + ctx.fillRect(28, 50 - walkLift, 4.5, 5); + } + + // 背包(仅背面与侧面可见,强化旅行者身份)。 + if (dir !== 'down') { + ctx.fillStyle = '#8a6a42'; + ctx.strokeStyle = '#5c4630'; + ctx.lineWidth = 1.5; + roundRect(ctx, 15, 31 + cy, 18, 14, 4); + ctx.fill(); + ctx.stroke(); + } + + // 身体(暖橙色圆角胶囊)。 + ctx.fillStyle = '#c96f4a'; + ctx.strokeStyle = '#6b4e2e'; + ctx.lineWidth = 2; + roundRect(ctx, 13, 32 + cy, 22, 20, 9); + ctx.fill(); + ctx.stroke(); + + // 头部。 + ctx.beginPath(); + ctx.arc(24, 18 + cy, 13, 0, Math.PI * 2); + ctx.fillStyle = '#f8dcc0'; + ctx.fill(); + ctx.stroke(); + + // 头发(深棕,按朝向变化覆盖区域)。 + ctx.fillStyle = '#4a3a28'; + ctx.beginPath(); + if (dir === 'up') { + // 背面:满头头发。 + ctx.arc(24, 18 + cy, 12.4, 0, Math.PI * 2); + } else if (dir === 'down') { + // 正面:头顶 + 两侧刘海。 + ctx.arc(24, 18 + cy, 12.4, Math.PI, Math.PI * 2); + ctx.moveTo(11.6, 18 + cy); + ctx.arc(24, 18 + cy, 12.4, Math.PI, Math.PI * 1.5); + ctx.rect(11.6, 12 + cy, 5, 7); + ctx.rect(31.4, 12 + cy, 5, 7); + } else { + // 侧面:后脑勺 + 单侧刘海。 + ctx.arc(24, 18 + cy, 12.4, Math.PI * 0.4, Math.PI * 1.6); + ctx.rect(30, 12 + cy, 6, 9); + } + ctx.fill(); + + // 表情(up 无表情细节)。 + if (dir !== 'up') { + ctx.fillStyle = '#33251a'; + if (dir === 'down') { + dot(ctx, 19, 21 + cy, 1.9); + dot(ctx, 29, 21 + cy, 1.9); + // 腮红。 + ctx.fillStyle = 'rgba(232, 122, 108, 0.45)'; + dot(ctx, 14.5, 25 + cy, 2.6); + dot(ctx, 33.5, 25 + cy, 2.6); + } else { + dot(ctx, 17, 21 + cy, 1.9); + ctx.fillStyle = 'rgba(232, 122, 108, 0.45)'; + dot(ctx, 13, 25 + cy, 2.4); + } + } + ctx.restore(); +} + +/** + * 绘制圆角矩形路径(兼容 ctx.roundRect 缺失的环境)。 + * + * @param {CanvasRenderingContext2D} ctx 画布上下文。 + * @param {number} x 左上角 x。 + * @param {number} y 左上角 y。 + * @param {number} w 宽。 + * @param {number} h 高。 + * @param {number} r 圆角半径。 + * @returns {void} + */ +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +/** + * 在指定圆心画实心圆点。 + * + * @param {CanvasRenderingContext2D} ctx 画布上下文。 + * @param {number} x 圆心 x。 + * @param {number} y 圆心 y。 + * @param {number} r 半径。 + * @returns {void} + */ +function dot(ctx, x, y, r) { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); +} + +/** + * 玩家角色精灵:输入汇总、物理移动与行走动画。 + * + * @extends Phaser.Physics.Arcade.Sprite + */ +export default class Player extends Phaser.Physics.Arcade.Sprite { + /** + * 创建玩家。 + * + * @param {Phaser.Scene} scene 所属场景。 + * @param {number} x 出生 x 坐标。 + * @param {number} y 出生 y 坐标。 + */ + constructor(scene, x, y) { + buildPlayerTexture(scene); + super(scene, x, y, 'player', 0); + scene.add.existing(this); + scene.physics.add.existing(this); + + this.speed = MOVE_SPEED; + this.setCollideWorldBounds(true); + this.setDepth(10); + this.currentDir = 'down'; + + // 注册四方向行走动画。 + [ + ['walk-down', 0], + ['walk-up', 2], + ['walk-left', 4], + ['walk-right', 6], + ].forEach(([key, base]) => { + scene.anims.create({ + key, + frames: [ + { key: 'player', frame: base }, + { key: 'player', frame: base + 1 }, + ], + frameRate: 7, + repeat: -1, + }); + }); + + /** @type {Phaser.Types.Input.Keyboard.CursorKeys} 方向键。 */ + this.cursors = scene.input.keyboard.createCursorKeys(); + /** @type {Object} WASD 键映射。 */ + this.wasd = scene.input.keyboard.addKeys('W,A,S,D'); + /** @type {Object|null} 虚拟摇杆(由 MapScene 注入,PC 端为 null)。 */ + this.joystick = null; + } + + /** + * 注入虚拟摇杆(移动端)。 + * + * @param {Object} joystick rexVirtualJoystick 实例。 + * @returns {void} + */ + setJoystick(joystick) { + this.joystick = joystick; + } + + /** + * 每帧更新:汇总键盘与摇杆输入,归一化后驱动移动与动画。 + * + * @returns {void} + */ + update() { + let vx = 0; + let vy = 0; + + if (this.cursors.left.isDown || this.wasd.A.isDown) vx = -1; + else if (this.cursors.right.isDown || this.wasd.D.isDown) vx = 1; + 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) { + vx = this.joystick.forceX; + vy = this.joystick.forceY; + } + + const len = Math.hypot(vx, vy); + if (len > 0.01) { + // 归一化,保证斜向移动不超速。 + this.setVelocity((vx / len) * this.speed, (vy / len) * this.speed); + // 按主分量决定朝向并播放行走动画。 + const dir = + Math.abs(vx) > Math.abs(vy) + ? vx > 0 + ? 'right' + : 'left' + : vy > 0 + ? 'down' + : 'up'; + this.currentDir = dir; + this.anims.play(`walk-${dir}`, true); + } else { + this.setVelocity(0, 0); + this.anims.stop(); + this.setFrame({ down: 0, up: 2, left: 4, right: 6 }[this.currentDir]); + } + } +} diff --git a/src/objects/PoiMarker.js b/src/objects/PoiMarker.js new file mode 100644 index 0000000..fc37992 --- /dev/null +++ b/src/objects/PoiMarker.js @@ -0,0 +1,134 @@ +/** + * @fileoverview 景点标记类(PoiMarker)。 + * + * 文件用途: + * - 在地图上渲染单个景点的可视化标记(触发圈 + 图标 + 名称 + 到访印章)。 + * + * 核心职责: + * 1. 双状态渲染:未解锁(虚线棕圈、半透明)/ 已解锁(实线橙圈、淡填充、印章); + * 2. unlock() 提供手账"盖章"切换动效(弹性缩放 + 印章浮现); + * 3. 标记整体半透明,保证玩家走入时仍可读。 + * + * 主要流程: + * MapScene 为每个景点创建 PoiMarker → state:changed 时批量调用 + * setUnlocked() → 解锁瞬间由 PhotoModal 关闭回调触发盖章动效。 + */ + +import Phaser from 'phaser'; + +/** 标记渲染深度(高于玩家,保证走进触发圈时仍可见)。 */ +const MARKER_DEPTH = 12; + +/** + * 景点标记容器:触发圈可视化 + 图标 + 名称 + 状态。 + * + * @extends Phaser.GameObjects.Container + */ +export default class PoiMarker extends Phaser.GameObjects.Container { + /** + * 创建景点标记。 + * + * @param {Phaser.Scene} scene 所属场景。 + * @param {Object} spot 景点配置(见 src/data/spots.js)。 + * @param {boolean} unlocked 初始是否已解锁(读档恢复)。 + */ + constructor(scene, spot, unlocked) { + super(scene, spot.x, spot.y); + scene.add.existing(this); + + /** @type {Object} 景点配置。 */ + this.spot = spot; + this.setDepth(MARKER_DEPTH); + + const r = spot.radius; + + // 触发圈。 + /** @type {Phaser.GameObjects.Arc} */ + this.ring = scene.add.circle(0, 0, r, 0xe67e22, unlocked ? 0.16 : 0.08); + this.ring.setStrokeStyle(3.5, unlocked ? 0xe67e22 : 0x8a6a42, unlocked ? 0.95 : 0.55); + this.ring.isStroked = true; + if (!unlocked) { + // 未解锁用虚线圈,弱化存在感。 + this.ring.setStrokeStyle(3, 0x8a6a42, 0.5); + } + + // 中心图标。 + /** @type {Phaser.GameObjects.Text} */ + this.iconText = scene.add + .text(0, 0, spot.icon, { fontSize: '38px' }) + .setOrigin(0.5); + + // 名称标签(纸片底 + 棕字)。 + /** @type {Phaser.GameObjects.Text} */ + this.nameText = scene.add + .text(0, -r - 16, spot.name, { + fontFamily: '"Kaiti SC", "KaiTi", serif', + fontSize: '22px', + color: '#5c4630', + backgroundColor: '#f5ecd7cc', + padding: { x: 10, y: 4 }, + }) + .setOrigin(0.5); + + // 已到访印章(初始隐藏,解锁时浮现)。 + /** @type {Phaser.GameObjects.Text} */ + this.stampText = scene.add + .text(r * 0.55, r * 0.55, '✓ 已到访', { + fontFamily: '"Kaiti SC", "KaiTi", serif', + fontSize: '20px', + color: '#c0392b', + backgroundColor: '#f8e8d8e0', + padding: { x: 6, y: 3 }, + }) + .setOrigin(0.5) + .setAngle(-12) + .setVisible(false); + + this.add([this.ring, this.iconText, this.nameText, this.stampText]); + if (unlocked) { + this.applyUnlockedStyle(false); + } + } + + /** + * 应用已解锁视觉样式。 + * + * @param {boolean} animate 是否播放盖章弹跳动效。 + * @returns {void} + */ + applyUnlockedStyle(animate) { + this.ring.setFillStyle(0xe67e22, 0.16); + this.ring.setStrokeStyle(4, 0xe67e22, 0.95); + this.iconText.setAlpha(1); + this.stampText.setVisible(true); + if (animate) { + this.setScale(1.45); + this.scene.tweens.add({ + targets: this, + scale: 1, + duration: 420, + ease: 'Back.easeOut', + }); + // 印章单独淡入,营造"盖章落印"节奏。 + this.stampText.setAlpha(0); + this.scene.tweens.add({ + targets: this.stampText, + alpha: 1, + scale: { from: 2.2, to: 1 }, + duration: 300, + delay: 120, + ease: 'Cubic.easeOut', + }); + } + } + + /** + * 外部入口:切换到已解锁状态。 + * + * @param {boolean} animate 是否播放动效(读档恢复时为 false)。 + * @returns {void} + */ + setUnlocked(animate = true) { + this.applyUnlockedStyle(animate); + } +} diff --git a/src/scenes/MapScene.js b/src/scenes/MapScene.js new file mode 100644 index 0000000..4ca8204 --- /dev/null +++ b/src/scenes/MapScene.js @@ -0,0 +1,243 @@ +/** + * @fileoverview 地图主场景(MapScene)。 + * + * 文件用途: + * - 承载手账地图漫游的全部游戏世界逻辑(唯一游戏场景)。 + * + * 核心职责: + * 1. 渲染分层地图:SVG 路网底图 + 纸张纹理叠加(multiply); + * 2. 玩家移动(键盘 + 触摸设备虚拟摇杆)与相机跟随; + * 3. 景点触发:Zone overlap 检测 + 弹窗互斥 + 离开冷却(防原地连弹); + * 4. POI 标记状态管理(读档恢复 / 解锁盖章动效)与屏幕边缘引导箭头; + * 5. 与 DOM UI 层通过 eventBus 通信(poi:reached / ui:closed)。 + * + * 主要流程: + * create() 搭建世界 → update() 每帧驱动玩家与引导 → + * overlap → poi:reached → PhotoModal 展示 → ui:closed → + * unlock + 冷却 + 恢复输入 → 玩家离开触发圈解除冷却。 + */ + +import Phaser from 'phaser'; +import Player from '../objects/Player.js'; +import PoiMarker from '../objects/PoiMarker.js'; +import GuideArrow from '../objects/GuideArrow.js'; +import { SPOTS, SPAWN } from '../data/spots.js'; +import { eventBus } from '../event-bus.js'; +import * as gameState from '../game-state.js'; +import { hasPaperTexture } from './PreloadScene.js'; +import { inputReleased } from '../ui/dom-layer.js'; + +/** 地图世界尺寸(与 SVG viewBox 一致)。 */ +const WORLD_W = 3000; +const WORLD_H = 2100; +/** 相机缩放(视野约 1280×720 世界单位)。 */ +const CAMERA_ZOOM = 1.25; +/** 触发圈外扩距离:玩家离开 radius+该值后解除冷却。 */ +const COOLDOWN_GAP = 30; + +/** + * 地图主场景。 + * + * @extends Phaser.Scene + */ +export default class MapScene extends Phaser.Scene { + constructor() { + super({ key: 'MapScene' }); + } + + /** + * 搭建地图世界:底图、纹理、标记、玩家、相机、摇杆、事件接线。 + * + * @returns {void} + */ + create() { + // ── 底图与纸张纹理叠加 ── + this.add.image(0, 0, 'mapBase').setOrigin(0, 0); + if (hasPaperTexture && this.textures.exists('paper')) { + const paper = this.add + .image(0, 0, 'paper') + .setOrigin(0, 0) + .setDisplaySize(WORLD_W, WORLD_H) + .setBlendMode(Phaser.BlendModes.MULTIPLY) + .setAlpha(0.5) + .setDepth(1); + paper.setVisible(true); + } + + // ── 物理世界边界 ── + this.physics.world.setBounds(0, 0, WORLD_W, WORLD_H); + + // ── 玩家 ── + this.player = new Player(this, SPAWN.x, SPAWN.y); + this.modalOpen = true; // 初始由封面页持有输入锁。 + this.setGameInputEnabled(false); + + // ── 相机 ── + const camera = this.cameras.main; + camera.setBounds(0, 0, WORLD_W, WORLD_H); + camera.setZoom(CAMERA_ZOOM); + camera.startFollow(this.player, true, 0.12, 0.12); + camera.setBackgroundColor('#f5ecd7'); + + // ── 景点标记与触发区 ── + /** @type {Map} 景点 id → 标记。 */ + this.markers = new Map(); + /** @type {Set} 处于冷却(刚看过、需离开才能重触发)的景点 id。 */ + this.cooldowns = new Set(); + SPOTS.forEach((spot) => { + const marker = new PoiMarker(this, spot, gameState.isUnlocked(spot.id)); + this.markers.set(spot.id, marker); + + const zone = this.add.zone(spot.x, spot.y, spot.radius * 2, spot.radius * 2); + this.physics.add.existing(zone, true); + this.physics.add.overlap(this.player, zone, () => this.onEnterZone(spot)); + }); + + // ── 引导箭头 ── + this.guideArrow = new GuideArrow(this); + + // ── 虚拟摇杆(仅触摸设备) ── + this.createVirtualJoystick(); + + // ── DOM UI 事件接线 ── + eventBus.on('ui:closed', ({ spotId }) => this.onModalClosed(spotId)); + + // 首帧前的进度广播:让 HUD 与封面页拿到初始快照。 + eventBus.emit('state:changed', gameState.getSnapshot()); + + // 封面"开始旅行"可能早于本场景创建(ESM live binding 回查放行标志)。 + if (inputReleased) { + this.modalOpen = false; + this.setGameInputEnabled(true); + } + } + + /** + * 创建虚拟摇杆(存在触摸能力时)。 + * + * @returns {void} + */ + createVirtualJoystick() { + const hasTouch = + 'ontouchstart' in window || (navigator.maxTouchPoints ?? 0) > 0; + if (!hasTouch || !this.rexVirtualJoystick) { + return; + } + const joystick = this.rexVirtualJoystick.add(this, { + x: 130, + y: this.scale.height - 110, + radius: 62, + base: this.add + .circle(0, 0, 62, 0xeadfc2, 0.55) + .setStrokeStyle(2.5, 0x8a6a42, 0.6), + thumb: this.add.circle(0, 0, 30, 0xf5ecd7, 0.9).setStrokeStyle(2, 0xb08a5e, 0.8), + enable: true, + }); + joystick.base.setScrollFactor(0).setDepth(100); + joystick.thumb.setScrollFactor(0).setDepth(101); + this.joystick = joystick; + this.player.setJoystick(joystick); + } + + /** + * 开关游戏输入(弹窗打开时禁用键盘与摇杆)。 + * + * @param {boolean} enabled 是否启用。 + * @returns {void} + */ + setGameInputEnabled(enabled) { + this.input.enabled = enabled; + if (this.joystick) { + this.joystick.setEnable(enabled); + } + } + + /** + * 玩家进入景点触发圈:请求弹窗(互斥 + 冷却过滤)。 + * + * @param {Object} spot 景点配置。 + * @returns {void} + */ + onEnterZone(spot) { + if (this.modalOpen || this.cooldowns.has(spot.id)) { + return; + } + this.modalOpen = true; + this.setGameInputEnabled(false); + eventBus.emit('poi:reached', { spot }); + } + + /** + * 弹窗关闭:解锁景点(首次)、进入冷却、恢复输入。 + * + * @param {string} spotId 关闭的弹窗对应景点 id。 + * @returns {void} + */ + onModalClosed(spotId) { + this.modalOpen = false; + this.setGameInputEnabled(true); + if (!spotId) { + return; // 封面页关闭(无景点上下文)。 + } + if (gameState.isUnlocked(spotId)) { + this.cooldowns.add(spotId); // 重看:同样进入冷却。 + return; + } + gameState.unlock(spotId); // 内部广播 state:changed。 + this.markers.get(spotId)?.setUnlocked(true); + this.cooldowns.add(spotId); + } + + /** + * 每帧更新:玩家移动、冷却解除检测、引导箭头。 + * + * @param {number} time 场景时间(毫秒)。 + * @returns {void} + */ + update(time) { + if (!this.modalOpen) { + this.player.update(); + } + + // 冷却解除:玩家离开触发圈足够远后可再次触发。 + 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); + } + }); + } + + // 引导目标:最近的未解锁景点。 + const unlocked = new Set(gameState.getSnapshot().unlocked); + let target = null; + let minDist = Infinity; + SPOTS.forEach((spot) => { + if (unlocked.has(spot.id)) { + return; + } + const dist = Phaser.Math.Distance.Between( + this.player.x, + this.player.y, + spot.x, + spot.y + ); + if (dist < minDist) { + minDist = dist; + target = spot; + } + }); + this.guideArrow.update(this.player, target, time); + } +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js new file mode 100644 index 0000000..3e13447 --- /dev/null +++ b/src/scenes/PreloadScene.js @@ -0,0 +1,147 @@ +/** + * @fileoverview 资源预加载场景(PreloadScene)。 + * + * 文件用途: + * - 以手账风进度条加载全部游戏资源,并对缺失资源做占位降级。 + * + * 核心职责: + * 1. 加载地图底图(SVG)、纸张纹理(AI 素材)与全部景点照片; + * 2. 渲染加载进度 UI(纸底圆角进度条 + 标题 + 百分比); + * 3. loaderror 处理:照片缺失时生成"照片待补"占位纹理,不中断启动; + * 4. 完成后跳转 MapScene。 + * + * 主要流程: + * preload() 注册资源与进度/失败监听 → create() 收尾并 scene.start('MapScene')。 + */ + +import Phaser from 'phaser'; +import { SPOTS } from '../data/spots.js'; + +/** 纸张纹理是否存在(供 MapScene 决定是否叠加纹理层)。 */ +export let hasPaperTexture = false; + +/** + * 资源预加载场景。 + * + * @extends Phaser.Scene + */ +export default class PreloadScene extends Phaser.Scene { + constructor() { + super({ key: 'PreloadScene' }); + } + + /** + * 注册资源加载与进度 UI。 + * + * @returns {void} + */ + preload() { + const { width, height } = this.scale; + + // ── 加载进度 UI(手账风) ── + this.cameras.main.setBackgroundColor('#f5ecd7'); + this.add + .text(width / 2, height / 2 - 110, '西安旅行记忆', { + fontFamily: '"Kaiti SC", "KaiTi", serif', + fontSize: '58px', + color: '#6b4e2e', + }) + .setOrigin(0.5); + this.add + .text(width / 2, height / 2 - 60, '正在翻开手账…', { + fontFamily: '"Kaiti SC", "KaiTi", serif', + fontSize: '22px', + color: '#b08a5e', + }) + .setOrigin(0.5); + + const barX = width / 2 - 240; + const barY = height / 2 + 20; + const barW = 480; + const track = this.add.graphics(); + track.fillStyle(0xeadfc2, 1); + track.fillRoundedRect(barX - 6, barY - 6, barW + 12, 32, 16); + track.lineStyle(2.5, 0xb08a5e, 1); + track.strokeRoundedRect(barX - 6, barY - 6, barW + 12, 32, 16); + const fill = this.add.graphics(); + const percentText = this.add + .text(width / 2, barY + 70, '0%', { + fontFamily: 'Georgia, serif', + fontSize: '20px', + color: '#8a6a42', + }) + .setOrigin(0.5); + + this.load.on('progress', (value) => { + fill.clear(); + fill.fillStyle(0xe67e22, 1); + fill.fillRoundedRect(barX, barY, Math.max(20, barW * value), 20, 10); + percentText.setText(`${Math.round(value * 100)}%`); + }); + + // ── 缺失资源降级:生成"照片待补"占位纹理 ── + this.load.on('loaderror', (file) => { + console.warn(`[preload] 资源缺失,启用占位降级: ${file.key}`); + if (file.key.startsWith('photo-')) { + this.buildPhotoPlaceholder(file.key); + } else if (file.key === 'paper') { + hasPaperTexture = false; + } + }); + + // ── 地图与纹理 ── + 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 渐变占位纹理(含"照片待补"水印)。 + * + * @param {string} textureKey 缺失的纹理 key。 + * @returns {void} + */ + buildPhotoPlaceholder(textureKey) { + if (this.textures.exists(textureKey)) { + return; + } + const w = 1200; + const h = 900; + const canvas = this.textures.createCanvas(textureKey, 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.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.fillStyle = 'rgba(107, 78, 46, 0.75)'; + ctx.font = '48px "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); + canvas.refresh(); + } + + /** + * 加载完成后进入主地图场景。 + * + * @returns {void} + */ + create() { + // 略作停顿让进度条走满展示,再转场。 + this.time.delayedCall(250, () => this.scene.start('MapScene')); + } +} diff --git a/src/ui/CompleteOverlay.js b/src/ui/CompleteOverlay.js new file mode 100644 index 0000000..95e2256 --- /dev/null +++ b/src/ui/CompleteOverlay.js @@ -0,0 +1,87 @@ +/** + * @fileoverview 旅行完成纪念页组件(CompleteOverlay)。 + * + * 文件用途: + * - 全部景点解锁后的全屏纪念页:照片九宫格 + 旅行统计 + 继续漫游。 + * + * 核心职责: + * 1. 监听 game:complete 展示纪念页(每个景点一张 mini 拍立得); + * 2. 统计口径:景点数、旅行天数(spots 中不重复 date 数量); + * 3. "继续漫游"关闭页面并交还输入(ui:closed)。 + * + * 主要流程: + * game-state 检测集齐 → game:complete → show() → 用户浏览/关闭 → + * emit('ui:closed', {}) → MapScene 恢复输入。 + */ + +import { eventBus } from '../event-bus.js'; +import { SPOTS } from '../data/spots.js'; + +/** + * 纪念页 DOM 组件。 + */ +export class CompleteOverlay { + /** + * 创建并挂载纪念页 DOM。 + * + * @param {HTMLElement} root #ui 根容器。 + */ + constructor(root) { + /** @type {HTMLElement} 组件根节点。 */ + this.el = document.createElement('section'); + this.el.className = 'complete-overlay hidden'; + this.el.innerHTML = ` +
+
+

🎉 旅行完成

+

这一程,长安的所有回忆都被你收进了手账

+
+
0天旅程
+
0个回忆
+
+
+ +
+
+ `; + root.appendChild(this.el); + this.el.querySelector('.co-continue').addEventListener('click', () => this.hide()); + eventBus.on('game:complete', () => this.show()); + } + + /** + * 展示纪念页:装配九宫格与统计。 + * + * @returns {void} + */ + show() { + const grid = this.el.querySelector('.co-grid'); + grid.innerHTML = ''; + SPOTS.forEach((spot) => { + const card = document.createElement('div'); + card.className = 'co-card'; + card.style.setProperty('--tilt', `${(spot.x % 11) - 5}deg`); + card.innerHTML = ` +
${spot.icon}
+
${spot.name}
+
${spot.date}
+ `; + grid.appendChild(card); + }); + + const days = new Set(SPOTS.map((s) => s.date)).size; + this.el.querySelector('#co-days').textContent = days; + this.el.querySelector('#co-spots').textContent = SPOTS.length; + this.el.classList.remove('hidden'); + } + + /** + * 隐藏纪念页并交还输入。 + * + * @returns {void} + */ + hide() { + this.el.classList.add('hidden'); + eventBus.emit('ui:closed', {}); + } +} diff --git a/src/ui/Hud.js b/src/ui/Hud.js new file mode 100644 index 0000000..0c64028 --- /dev/null +++ b/src/ui/Hud.js @@ -0,0 +1,57 @@ +/** + * @fileoverview 收集进度 HUD 组件。 + * + * 文件用途: + * - 屏幕右上角的常驻进度徽章(n / total),解锁时数字跳动。 + * + * 核心职责: + * 1. 监听 state:changed 刷新进度文本; + * 2. 进度变化时触发 pop 动效(重排 CSS animation)。 + * + * 主要流程: + * init() 挂载并订阅事件 → state:changed → 更新文本 + pop。 + */ + +import { eventBus } from '../event-bus.js'; + +/** + * HUD 进度徽章 DOM 组件。 + */ +export class Hud { + /** + * 创建并挂载徽章 DOM。 + * + * @param {HTMLElement} root #ui 根容器。 + */ + constructor(root) { + /** @type {HTMLElement} 组件根节点。 */ + this.el = document.createElement('div'); + this.el.className = 'hud'; + this.el.innerHTML = ` + 🗺️ + 0 / 0 + `; + root.appendChild(this.el); + /** @type {HTMLElement} 数字节点。 */ + this.countEl = this.el.querySelector('.hud-count'); + } + + /** + * 初始化:读取初始快照并订阅进度事件。 + * + * @returns {void} + */ + init() { + eventBus.on('state:changed', (snapshot) => { + const text = `${snapshot.unlocked.length} / ${snapshot.total}`; + const changed = this.countEl.textContent !== text; + this.countEl.textContent = text; + if (changed) { + // 重排 animation 使数字跳动每次都触发。 + this.el.classList.remove('pop'); + void this.el.offsetWidth; + this.el.classList.add('pop'); + } + }); + } +} diff --git a/src/ui/PhotoModal.js b/src/ui/PhotoModal.js new file mode 100644 index 0000000..444b078 --- /dev/null +++ b/src/ui/PhotoModal.js @@ -0,0 +1,175 @@ +/** + * @fileoverview 拍立得照片弹窗组件(PhotoModal)。 + * + * 文件用途: + * - 景点触发后的全屏弹窗:拍立得照片叠沓翻看 + 手账文案 + 关闭恢复。 + * + * 核心职责: + * 1. 监听 poi:reached 展示景点照片(多张时叠成一沓,点击翻下一张); + * 2. 拍立得视觉:白边相纸 + 手写 caption + 角标日期 + 轻微随机旋转; + * 3. 照片缺失降级:img 加载失败时替换为"照片待补"占位卡; + * 4. 关闭(按钮 / 遮罩 / ESC / 空格)后广播 ui:closed 交还输入。 + * + * 主要流程: + * MapScene overlap → poi:reached → show(spot) → 用户翻看/关闭 → + * emit('ui:closed', { spotId }) → MapScene 解锁并进入冷却。 + */ + +import { eventBus } from '../event-bus.js'; +import * as gameState from '../game-state.js'; + +/** + * 拍立得弹窗 DOM 组件。 + */ +export class PhotoModal { + /** + * 创建并挂载弹窗 DOM,监听 poi:reached。 + * + * @param {HTMLElement} root #ui 根容器。 + */ + constructor(root) { + /** @type {Object|null} 当前展示的景点。 */ + this.spot = null; + /** @type {number} 当前翻到的照片序号。 */ + this.index = 0; + /** @type {boolean} 翻页防抖锁(300ms 动画期间忽略连点)。 */ + this.flipping = false; + /** @type {Function|null} ESC 监听器(展示时绑定,关闭时解绑)。 */ + this.keyHandler = null; + + /** @type {HTMLElement} 组件根节点。 */ + this.el = document.createElement('section'); + this.el.className = 'photo-modal hidden'; + this.el.innerHTML = ` +
+
+
+ + + +
+
+

+

点击照片翻看下一张 · 点击空白处关闭

+
+ + `; + root.appendChild(this.el); + + this.stack = this.el.querySelector('.pm-stack'); + this.el.querySelector('.pm-backdrop').addEventListener('click', () => this.close()); + this.el.querySelector('.pm-close').addEventListener('click', () => this.close()); + eventBus.on('poi:reached', ({ spot }) => this.show(spot)); + } + + /** + * 展示景点拍立得弹窗。 + * + * @param {Object} spot 景点配置。 + * @returns {void} + */ + show(spot) { + this.spot = spot; + this.index = 0; + this.renderStack(); + + this.el.querySelector('.pm-name').textContent = spot.name; + this.el.querySelector('.pm-count').textContent = + spot.photos.length > 1 ? `${this.index + 1} / ${spot.photos.length}` : ''; + this.el.querySelector('.pm-story').textContent = spot.story; + this.el.querySelector('.pm-badge').hidden = gameState.isUnlocked(spot.id); + this.el.classList.remove('hidden'); + + this.keyHandler = (e) => { + if (e.key === 'Escape' || e.key === ' ') { + e.preventDefault(); + this.close(); + } + }; + window.addEventListener('keydown', this.keyHandler); + } + + /** + * 渲染拍立得叠沓:当前张置顶,后续两张错位垫底。 + * + * @returns {void} + */ + renderStack() { + const { photos, date } = this.spot; + this.stack.innerHTML = ''; + const visible = Math.min(3, photos.length - this.index); + for (let i = 0; i < visible; i += 1) { + const photoIdx = this.index + i; + const card = document.createElement('figure'); + card.className = `polaroid pos-${i}`; + // 每张固定随机小角度,营造随手摆放的手账感。 + card.style.setProperty('--tilt', `${(photoIdx * 13) % 9 - 4}deg`); + card.innerHTML = ` +
+ ${photos[photoIdx].caption} +
+
${photos[photoIdx].caption}
+ ${date} + `; + // 照片缺失降级为"照片待补"占位。 + const img = card.querySelector('img'); + img.addEventListener('error', () => { + const fallback = document.createElement('div'); + fallback.className = 'pl-missing'; + fallback.innerHTML = '📷 照片待补'; + img.replaceWith(fallback); + }); + // 点击当前张翻下一张(末张回到第一张循环)。 + card.addEventListener('click', () => { + if (i === 0) { + this.flipNext(); + } + }); + this.stack.appendChild(card); + } + } + + /** + * 翻页:当前张飞出 → 300ms 后切换序号重渲叠沓(带防抖锁防连点)。 + * + * @returns {void} + */ + flipNext() { + if (this.flipping) { + return; + } + const top = this.stack.querySelector('.pos-0'); + if (!top) { + return; + } + this.flipping = true; + top.classList.add('fly'); + const { photos } = this.spot; + const next = (this.index + 1) % photos.length; + setTimeout(() => { + this.index = next; + this.flipping = false; + this.renderStack(); + this.el.querySelector('.pm-count').textContent = + photos.length > 1 ? `${this.index + 1} / ${photos.length}` : ''; + }, 300); + } + + /** + * 关闭弹窗并交还输入给 Phaser 层。 + * + * @returns {void} + */ + close() { + if (this.el.classList.contains('hidden')) { + return; + } + this.el.classList.add('hidden'); + if (this.keyHandler) { + window.removeEventListener('keydown', this.keyHandler); + this.keyHandler = null; + } + eventBus.emit('ui:closed', { spotId: this.spot?.id }); + this.spot = null; + } +} diff --git a/src/ui/TitleOverlay.js b/src/ui/TitleOverlay.js new file mode 100644 index 0000000..0805b7f --- /dev/null +++ b/src/ui/TitleOverlay.js @@ -0,0 +1,118 @@ +/** + * @fileoverview 封面页组件(TitleOverlay)。 + * + * 文件用途: + * - 首屏手账封面:标题、进度概览、开始/继续旅行与重新开始入口。 + * + * 核心职责: + * 1. 渲染手账封面(纸纹底 + 大标题 + 装饰拍立得 + 胶带贴纸); + * 2. 有存档时显示"继续旅行 (n/total)"并附"重新开始"次级入口; + * 3. 点击开始后放行游戏输入(ui:release-input / ui:closed)。 + * + * 主要流程: + * mountUi 调用 show() → 用户点击主按钮 → hide + 双事件放行 → + * MapScene 接管输入(若尚未创建则回查 inputReleased 标志)。 + */ + +import { eventBus } from '../event-bus.js'; +import * as gameState from '../game-state.js'; + +/** + * 封面页 DOM 组件。 + */ +export class TitleOverlay { + /** + * 创建并挂载封面 DOM。 + * + * @param {HTMLElement} root #ui 根容器。 + */ + constructor(root) { + /** @type {HTMLElement} 组件根节点。 */ + this.el = document.createElement('section'); + this.el.className = 'title-overlay'; + this.el.innerHTML = ` +
+
+
+
+
+
🗺️
+
国庆 · 长安行
+
+

西安旅行记忆

+

一座可以走进去的旅行手账

+ + + +

走进地图上的景点,翻开属于你的拍立得回忆

+
+
+ `; + root.appendChild(this.el); + + this.el.querySelector('.to-start').addEventListener('click', () => this.handleStart()); + this.el.querySelector('.to-reset').addEventListener('click', () => this.handleReset()); + } + + /** + * 显示封面(按存档状态装配文案)。 + * + * @param {Object} options 显示选项。 + * @param {boolean} options.hasSave 是否存在存档。 + * @param {Object} options.snapshot 进度快照。 + * @returns {void} + */ + show({ hasSave, snapshot }) { + const startBtn = this.el.querySelector('.to-start'); + const resetBtn = this.el.querySelector('.to-reset'); + const progress = this.el.querySelector('.to-progress'); + if (hasSave && snapshot.unlocked.length > 0) { + startBtn.textContent = `继续旅行`; + resetBtn.hidden = false; + progress.hidden = false; + progress.textContent = `已收集 ${snapshot.unlocked.length} / ${snapshot.total} 个回忆`; + } else { + startBtn.textContent = '开始旅行'; + resetBtn.hidden = true; + progress.hidden = true; + } + this.el.classList.remove('hidden'); + } + + /** + * 隐藏封面。 + * + * @returns {void} + */ + hide() { + this.el.classList.add('hidden'); + } + + /** + * 开始/继续:放行输入并关闭封面(带翻页淡出过渡)。 + * + * @returns {void} + */ + handleStart() { + this.el.classList.add('leaving'); + eventBus.emit('ui:release-input'); + eventBus.emit('ui:closed', {}); + setTimeout(() => { + this.hide(); + this.el.classList.remove('leaving'); + }, 600); + } + + /** + * 重新开始:确认后清档并整页刷新。 + * + * @returns {void} + */ + handleReset() { + const confirmed = window.confirm('确定清空全部收集进度,重新开始这次旅行吗?'); + if (confirmed) { + gameState.resetProgress(); + window.location.reload(); + } + } +} diff --git a/src/ui/dom-layer.js b/src/ui/dom-layer.js new file mode 100644 index 0000000..f80bbe2 --- /dev/null +++ b/src/ui/dom-layer.js @@ -0,0 +1,66 @@ +/** + * @fileoverview DOM UI 层管理器(dom-layer)。 + * + * 文件用途: + * - 挂载并协调全部 DOM UI 组件(封面 / 拍立得弹窗 / HUD / 纪念页 / 竖屏提示)。 + * + * 核心职责: + * 1. 组件生命周期:mount 后由各组件自行监听 eventBus 事件驱动显示; + * 2. 维护 inputReleased 标志:封面"开始旅行"放行输入(MapScene 可能晚于 + * 事件创建,需在 create 时回查该标志,避免输入锁死); + * 3. 组件互斥由组件自身在 show/hide 时控制,层管理器只负责装配。 + * + * 主要流程: + * main.js 调用 mountUi() → 各组件挂载到 #ui 容器 → TitleOverlay 首屏 + * 显示 → 用户交互 → 各组件 show/hide 并通过 eventBus 通知 Phaser 层。 + */ + +import { eventBus } from '../event-bus.js'; +import * as gameState from '../game-state.js'; +import { TitleOverlay } from './TitleOverlay.js'; +import { PhotoModal } from './PhotoModal.js'; +import { Hud } from './Hud.js'; +import { CompleteOverlay } from './CompleteOverlay.js'; +import { initRotateHint } from './rotate-hint.js'; + +/** + * 游戏输入是否已被 UI 放行(封面页"开始旅行"后为 true)。 + * + * @type {boolean} + */ +export let inputReleased = false; + +/** + * 放行游戏输入并广播 ui:closed(MapScene create 晚于事件时回查本标志)。 + * + * @returns {void} + */ +export function releaseInput() { + inputReleased = true; +} + +/** + * 装配 DOM UI 层:创建根节点并挂载全部组件。 + * + * @returns {void} + */ +export function mountUi() { + const root = document.getElementById('ui'); + + // 事件桥上暴露放行动作,供封面页调用。 + eventBus.on('ui:release-input', () => releaseInput()); + + const hud = new Hud(root); + const title = new TitleOverlay(root); + const photoModal = new PhotoModal(root); + const complete = new CompleteOverlay(root); + + // 组件装配后初始化:封面首屏显示(覆盖资源加载过程),其余待事件驱动。 + title.show({ + hasSave: gameState.hasSave(), + snapshot: gameState.loadProgress(), + }); + hud.init(); + + initRotateHint(); +} diff --git a/src/ui/rotate-hint.js b/src/ui/rotate-hint.js new file mode 100644 index 0000000..6452eab --- /dev/null +++ b/src/ui/rotate-hint.js @@ -0,0 +1,52 @@ +/** + * @fileoverview 竖屏提示组件(rotate-hint)。 + * + * 文件用途: + * - 触摸设备处于竖屏时全屏提示旋转手机,回到横屏自动恢复。 + * + * 核心职责: + * 1. 检测触摸能力与屏幕方向(orientation media query + resize 兜底); + * 2. 控制 index.html 中 #rotate-hint 的显示与隐藏。 + * + * 主要流程: + * initRotateHint() 注册监听 → 竖屏 + 触摸 → 显示提示层 → 横屏 → 隐藏。 + */ + +/** + * 初始化竖屏提示监听。 + * + * @returns {void} + */ +export function initRotateHint() { + /** @type {HTMLElement} 提示层节点。 */ + const el = document.getElementById('rotate-hint'); + if (!el) { + return; + } + + /** + * 判断当前是否应显示提示(触摸设备 + 竖屏)。 + * + * @returns {boolean} 需要提示返回 true。 + */ + const shouldShow = () => { + const hasTouch = + 'ontouchstart' in window || (navigator.maxTouchPoints ?? 0) > 0; + const portrait = window.innerHeight > window.innerWidth; + return hasTouch && portrait; + }; + + /** + * 应用显示状态。 + * + * @returns {void} + */ + const apply = () => { + el.classList.toggle('show', shouldShow()); + }; + + const mql = window.matchMedia('(orientation: portrait)'); + mql.addEventListener?.('change', apply); + window.addEventListener('resize', apply); + apply(); +} diff --git a/src/ui/styles.css b/src/ui/styles.css new file mode 100644 index 0000000..47564d6 --- /dev/null +++ b/src/ui/styles.css @@ -0,0 +1,651 @@ +/** + * @fileoverview DOM UI 层全局样式(手账旅行风)。 + * + * 文件用途: + * - 定义全站视觉基调与各 UI 组件样式:封面页、拍立得弹窗、HUD、 + * 纪念页、竖屏提示、通用按钮。 + * + * 核心职责: + * 1. 视觉基调:羊皮纸底 / 棕褐墨色 / 暖橙强调 / 楷体手写感; + * 2. 布局体系:#ui 覆盖层(pointer-events 按组件开合)与全屏组件纸底; + * 3. 动效:拍立得叠沓翻页、封面翻页淡出、HUD 数字跳动、盖章质感。 + * + * 主要流程: + * index.html 引入 → 各组件仅依赖 class 命名(BEM 式前缀),无内联样式依赖。 + */ + +/* ══════════ 视觉基调变量 ══════════ */ +:root { + --paper: #f5ecd7; + --paper-deep: #efe3c8; + --ink: #6b4e2e; + --ink-soft: #8a6a42; + --ink-faint: #b08a5e; + --accent: #e67e22; + --accent-deep: #c96f4a; + --card: #fffdf7; + --shadow: 0 6px 18px rgba(60, 42, 20, 0.35); + --font-hand: 'Kaiti SC', 'STKaiti', 'KaiTi', serif; + --font-body: 'Kaiti SC', 'STKaiti', 'KaiTi', serif; +} + +/* ══════════ 全局重置 ══════════ */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + touch-action: none; + -webkit-user-select: none; + user-select: none; + background: var(--paper); + font-family: var(--font-body); + color: var(--ink); +} + +#game { + position: fixed; + inset: 0; +} + +/* UI 层:覆盖全屏,子组件自行开启 pointer-events。 */ +#ui { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10; +} + +#ui > * { + pointer-events: auto; +} + +.hidden { + display: none !important; +} + +/* ══════════ 通用按钮 ══════════ */ +.btn-primary { + font-family: var(--font-hand); + font-size: clamp(18px, 2.6vmin, 26px); + color: var(--ink); + background: var(--card); + border: 2.5px solid var(--ink-soft); + border-radius: 999px; + padding: 0.55em 2.2em; + cursor: pointer; + box-shadow: 0 3px 0 rgba(107, 78, 46, 0.35); + transition: transform 0.18s ease, box-shadow 0.18s ease; + letter-spacing: 0.12em; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 5px 0 rgba(107, 78, 46, 0.35); +} + +.btn-primary:active { + transform: translateY(1px); + box-shadow: 0 1px 0 rgba(107, 78, 46, 0.35); +} + +.btn-ghost { + font-family: var(--font-hand); + font-size: clamp(14px, 1.8vmin, 17px); + color: var(--ink-faint); + background: none; + border: none; + border-bottom: 1.5px dashed var(--ink-faint); + cursor: pointer; + padding: 0.3em 0.6em; + letter-spacing: 0.1em; +} + +.btn-ghost:hover { + color: var(--ink-soft); + border-bottom-color: var(--ink-soft); +} + +/* ══════════ 封面页 ══════════ */ +.title-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.title-overlay.leaving { + opacity: 0; + transform: scale(1.04); + pointer-events: none; +} + +/* 纸张底:多层渐变模拟羊皮纸 + 边缘做旧。 */ +.to-paper { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(ellipse at 28% 22%, rgba(255, 251, 235, 0.7), transparent 55%), + radial-gradient(ellipse at 75% 78%, rgba(190, 155, 100, 0.22), transparent 50%), + radial-gradient(ellipse at 50% 50%, transparent 40%, rgba(160, 125, 80, 0.16) 100%), + var(--paper); + box-shadow: inset 0 0 120px rgba(150, 115, 70, 0.35); +} + +.to-content { + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(10px, 2.2vmin, 22px); + text-align: center; +} + +/* 封面装饰拍立得。 */ +.to-polaroid { + background: var(--card); + padding: 10px 10px 14px; + box-shadow: var(--shadow); + transform: rotate(-3deg); + margin-bottom: clamp(6px, 1.5vmin, 14px); +} + +.to-cover-photo { + width: clamp(120px, 18vmin, 190px); + height: clamp(90px, 13.5vmin, 142px); + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(48px, 8vmin, 84px); + background: linear-gradient(150deg, #f6e7c8, #edd9b0); + border-radius: 2px; +} + +.to-cover-caption { + font-family: var(--font-hand); + font-size: clamp(13px, 1.8vmin, 17px); + color: var(--ink-soft); + padding-top: 8px; + letter-spacing: 0.2em; +} + +.to-title { + font-family: var(--font-hand); + font-size: clamp(38px, 7.5vmin, 78px); + color: var(--ink); + letter-spacing: 0.14em; + text-shadow: 2px 3px 0 rgba(201, 168, 117, 0.35); +} + +.to-subtitle { + font-family: var(--font-hand); + font-size: clamp(15px, 2.2vmin, 22px); + color: var(--ink-faint); + letter-spacing: 0.32em; +} + +.to-progress { + font-family: var(--font-hand); + font-size: clamp(13px, 1.9vmin, 18px); + color: var(--accent-deep); + background: rgba(230, 126, 34, 0.12); + border: 1.5px dashed rgba(230, 126, 34, 0.5); + border-radius: 999px; + padding: 0.3em 1.4em; +} + +.to-start { + margin-top: clamp(4px, 1vmin, 10px); +} + +.to-reset { + margin-top: -4px; +} + +.to-foot { + position: absolute; + bottom: clamp(18px, 4vmin, 40px); + font-size: clamp(12px, 1.6vmin, 15px); + color: var(--ink-faint); + letter-spacing: 0.14em; + opacity: 0.85; +} + +/* 胶带贴纸装饰。 */ +.to-deco { + position: absolute; + width: clamp(90px, 14vmin, 150px); + height: clamp(26px, 4vmin, 40px); + background: rgba(233, 214, 160, 0.65); + border-left: 1px dashed rgba(160, 130, 80, 0.4); + border-right: 1px dashed rgba(160, 130, 80, 0.4); + box-shadow: 0 2px 6px rgba(80, 60, 40, 0.18); +} + +.to-tape-1 { + top: 9%; + left: 12%; + transform: rotate(-18deg); +} + +.to-tape-2 { + bottom: 14%; + right: 10%; + transform: rotate(24deg); +} + +/* ══════════ 拍立得弹窗 ══════════ */ +.photo-modal { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.pm-backdrop { + position: absolute; + inset: 0; + background: rgba(50, 36, 20, 0.55); + backdrop-filter: blur(2px); +} + +.pm-stage { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(8px, 1.8vmin, 18px); + max-width: min(84vw, 760px); + animation: pm-enter 0.38s cubic-bezier(0.2, 1.4, 0.4, 1); +} + +@keyframes pm-enter { + from { + opacity: 0; + transform: translateY(26px) scale(0.92); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.pm-header { + display: flex; + align-items: center; + gap: 14px; +} + +.pm-name { + font-family: var(--font-hand); + font-size: clamp(26px, 4.2vmin, 42px); + color: #fff8ec; + letter-spacing: 0.18em; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.pm-badge { + font-family: var(--font-hand); + font-size: clamp(12px, 1.7vmin, 16px); + color: #ffdf9e; + background: rgba(201, 111, 74, 0.85); + border-radius: 999px; + padding: 0.25em 0.9em; + transform: rotate(-2deg); + animation: badge-wiggle 1.6s ease-in-out infinite; +} + +@keyframes badge-wiggle { + 0%, 100% { transform: rotate(-2deg) scale(1); } + 50% { transform: rotate(2deg) scale(1.05); } +} + +.pm-count { + font-family: var(--font-hand); + font-size: clamp(13px, 1.8vmin, 17px); + color: rgba(255, 248, 236, 0.75); +} + +/* 拍立得叠沓。 */ +.pm-stack { + position: relative; + width: clamp(300px, 52vmin, 560px); + aspect-ratio: 4 / 4.55; +} + +.polaroid { + position: absolute; + inset: 0; + background: var(--card); + padding: 4.5% 4.5% 12%; + box-shadow: var(--shadow); + transform: rotate(var(--tilt, 0deg)); + cursor: pointer; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.polaroid.pos-0 { + z-index: 3; +} + +.polaroid.pos-0:hover { + transform: rotate(var(--tilt, 0deg)) scale(1.02); +} + +.polaroid.pos-1 { + z-index: 2; + transform: rotate(calc(var(--tilt, 0deg) + 4deg)) translateY(4.5%) scale(0.97); + filter: brightness(0.96); +} + +.polaroid.pos-2 { + z-index: 1; + transform: rotate(calc(var(--tilt, 0deg) + 8deg)) translateY(9%) scale(0.94); + filter: brightness(0.92); +} + +/* 翻页飞出动画。 */ +.polaroid.fly { + transform: translateX(135%) rotate(16deg) scale(0.9); + opacity: 0; +} + +.pl-photo { + width: 100%; + aspect-ratio: 4 / 3; + background: var(--paper-deep); + border-radius: 2px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.pl-photo img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.pl-missing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: 100%; + font-family: var(--font-hand); + font-size: clamp(16px, 2.4vmin, 24px); + color: var(--ink-soft); + border: 2.5px dashed rgba(138, 106, 66, 0.4); + border-radius: 4px; +} + +.pl-caption { + font-family: var(--font-hand); + font-size: clamp(15px, 2.2vmin, 22px); + color: var(--ink-soft); + text-align: center; + margin-top: 3.5%; + letter-spacing: 0.08em; +} + +.pl-date { + position: absolute; + right: 6%; + bottom: 3.5%; + font-family: var(--font-hand); + font-size: clamp(11px, 1.6vmin, 15px); + color: var(--ink-faint); + transform: rotate(-1.5deg); +} + +.pm-story { + font-family: var(--font-hand); + font-size: clamp(14px, 2vmin, 19px); + color: rgba(255, 248, 236, 0.9); + text-align: center; + max-width: 90%; + line-height: 1.7; + letter-spacing: 0.06em; +} + +.pm-hint { + font-family: var(--font-hand); + font-size: clamp(11px, 1.5vmin, 14px); + color: rgba(255, 248, 236, 0.55); + letter-spacing: 0.12em; +} + +.pm-close { + position: absolute; + top: clamp(14px, 3vmin, 30px); + right: clamp(14px, 3vmin, 30px); + width: clamp(38px, 5.5vmin, 52px); + height: clamp(38px, 5.5vmin, 52px); + border-radius: 50%; + border: 2px solid rgba(255, 248, 236, 0.7); + background: rgba(80, 56, 30, 0.5); + color: #fff8ec; + font-size: clamp(16px, 2.4vmin, 22px); + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} + +.pm-close:hover { + background: rgba(201, 111, 74, 0.9); + transform: rotate(90deg); +} + +/* ══════════ HUD ══════════ */ +.hud { + position: absolute; + top: calc(clamp(12px, 2.4vmin, 24px) + env(safe-area-inset-top)); + right: calc(clamp(12px, 2.4vmin, 24px) + env(safe-area-inset-right)); + display: flex; + align-items: center; + gap: 8px; + background: rgba(245, 236, 215, 0.88); + border: 2px solid var(--ink-soft); + border-radius: 999px; + padding: 0.35em 1.1em; + box-shadow: 0 3px 10px rgba(60, 42, 20, 0.25); + pointer-events: none; +} + +.hud-icon { + font-size: clamp(16px, 2.4vmin, 22px); +} + +.hud-count { + font-family: var(--font-hand); + font-size: clamp(14px, 2vmin, 19px); + color: var(--ink); + letter-spacing: 0.1em; +} + +.hud.pop { + animation: hud-pop 0.5s cubic-bezier(0.2, 1.6, 0.4, 1); +} + +@keyframes hud-pop { + 0% { transform: scale(1); } + 40% { transform: scale(1.18) rotate(-2deg); } + 100% { transform: scale(1); } +} + +/* ══════════ 纪念页 ══════════ */ +.complete-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; +} + +.co-paper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(ellipse at 30% 20%, rgba(255, 251, 235, 0.7), transparent 55%), + radial-gradient(ellipse at 75% 80%, rgba(190, 155, 100, 0.25), transparent 50%), + var(--paper); + box-shadow: inset 0 0 120px rgba(150, 115, 70, 0.35); + animation: co-enter 0.5s ease; +} + +@keyframes co-enter { + from { opacity: 0; transform: scale(1.05); } + to { opacity: 1; transform: scale(1); } +} + +.co-content { + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(10px, 2vmin, 20px); + text-align: center; + padding: clamp(20px, 5vmin, 50px) clamp(16px, 4vmin, 40px); + max-height: 100%; +} + +.co-title { + font-family: var(--font-hand); + font-size: clamp(32px, 6vmin, 62px); + color: var(--ink); + letter-spacing: 0.14em; +} + +.co-subtitle { + font-family: var(--font-hand); + font-size: clamp(14px, 2vmin, 20px); + color: var(--ink-faint); + letter-spacing: 0.2em; +} + +.co-stats { + display: flex; + gap: clamp(24px, 5vmin, 60px); + margin: clamp(4px, 1vmin, 10px) 0; +} + +.co-stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.co-stat b { + font-family: var(--font-hand); + font-size: clamp(30px, 5.5vmin, 56px); + color: var(--accent-deep); +} + +.co-stat span { + font-family: var(--font-hand); + font-size: clamp(13px, 1.8vmin, 17px); + color: var(--ink-soft); + letter-spacing: 0.2em; +} + +/* 九宫格(每景点一张 mini 拍立得)。 */ +.co-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: clamp(10px, 1.8vmin, 18px); + margin-top: clamp(6px, 1.4vmin, 14px); +} + +/* 小屏(手机横屏)降为两行布局。 */ +@media (max-width: 860px) { + .co-grid { + grid-template-columns: repeat(5, 1fr); + gap: 8px; + } +} + +.co-card { + background: var(--card); + padding: 8% 6% 12%; + box-shadow: 0 3px 10px rgba(60, 42, 20, 0.28); + transform: rotate(var(--tilt, 0deg)); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.co-photo { + font-size: clamp(24px, 4.6vmin, 46px); +} + +.co-name { + font-family: var(--font-hand); + font-size: clamp(11px, 1.7vmin, 17px); + color: var(--ink); + white-space: nowrap; +} + +.co-date { + font-family: var(--font-hand); + font-size: clamp(9px, 1.4vmin, 13px); + color: var(--ink-faint); +} + +.co-continue { + margin-top: clamp(10px, 2.4vmin, 24px); +} + +/* ══════════ 竖屏提示 ══════════ */ +#rotate-hint { + position: fixed; + inset: 0; + z-index: 100; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + background: var(--paper); + text-align: center; +} + +#rotate-hint.show { + display: flex; +} + +.rotate-phone { + font-size: 64px; + animation: phone-rotate 1.8s ease-in-out infinite; +} + +@keyframes phone-rotate { + 0%, 25% { transform: rotate(0deg); } + 60%, 100% { transform: rotate(90deg); } +} + +#rotate-hint p { + font-family: var(--font-hand); + font-size: 18px; + color: var(--ink-soft); + line-height: 1.8; + letter-spacing: 0.1em; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..0eeff27 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,25 @@ +/** + * @fileoverview Vite 构建配置。 + * + * 文件用途: + * - 定义西安旅行记忆游戏的开发服务器与生产构建行为。 + * + * 核心职责: + * 1. base 使用相对路径 './',便于部署到 GitHub Pages 子目录或任意静态目录; + * 2. 关闭资源内联(assetsInlineLimit: 0),避免大图被转 base64 拖慢加载; + * 3. 开发服务器开放局域网访问(host: true),方便手机真机调试。 + */ + +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', + build: { + outDir: 'dist', + assetsInlineLimit: 0, + }, + server: { + host: true, + port: 3000, + }, +});