feat: 初始化西安旅行记忆互动地图游戏项目
完成项目基础搭建:配置 Vite 开发构建环境,集成 Phaser 3 与虚拟摇杆插件,实现地图漫游、景点触发、拍立得弹窗、进度存档等核心功能,添加占位资源与批量生成脚本,配置项目元数据与忽略规则。
No files matched your search
@@ -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.*
|
||||||
@@ -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 打包产物、环境密钥、日志、编辑器与系统临时文件
|
||||||
@@ -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
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,
|
||||||
|
maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>西安旅行记忆</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none; /* 禁止触摸滚动 */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
#game-container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game-container"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</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/)。
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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 优先交付,减少前期装机量 |
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0,
|
||||||
|
user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#f5ecd7" />
|
||||||
|
<title>西安旅行记忆</title>
|
||||||
|
<link rel="stylesheet" href="/src/ui/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 游戏世界层:Phaser Canvas 挂载点(下层) -->
|
||||||
|
<div id="game"></div>
|
||||||
|
|
||||||
|
<!-- 手账 UI 层:封面 / 拍立得弹窗 / HUD / 纪念页(上层,按需开合 pointer-events) -->
|
||||||
|
<div id="ui"></div>
|
||||||
|
|
||||||
|
<!-- 竖屏提示层:触摸设备竖屏时全屏覆盖 -->
|
||||||
|
<div id="rotate-hint" aria-hidden="true">
|
||||||
|
<div class="rotate-phone">📱</div>
|
||||||
|
<p>请旋转手机至横屏<br />获得最佳旅行体验</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
西安手账地图底图(SVG 路网层)
|
||||||
|
- 坐标系:viewBox 0 0 3000 2100,约 8.3 米/单位,景点触发坐标见 src/data/spots.js
|
||||||
|
- 风格:羊皮纸底 + 棕褐路网 + 淡蓝水系;纸张纹理由 MapScene 叠加 AI 纹理层实现
|
||||||
|
- 景点标记不在此层绘制(由 Phaser PoiMarker 渲染,支持已/未解锁状态切换)
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="3000" height="2100" viewBox="0 0 3000 2100">
|
||||||
|
<defs>
|
||||||
|
<!-- 水系波光渐变 -->
|
||||||
|
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#b8d4e4" />
|
||||||
|
<stop offset="1" stop-color="#a4c3d6" />
|
||||||
|
</linearGradient>
|
||||||
|
<!-- 城墙渐变 -->
|
||||||
|
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#e6d8b8" />
|
||||||
|
<stop offset="1" stop-color="#dccba6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- ═══ 羊皮纸底色 ═══ -->
|
||||||
|
<rect width="3000" height="2100" fill="#f5ecd7" />
|
||||||
|
|
||||||
|
<!-- ═══ 城区淡彩(区域感,不追求精确) ═══ -->
|
||||||
|
<rect x="620" y="520" width="1760" height="1200" rx="90" fill="#efe3c8" opacity="0.45" />
|
||||||
|
<!-- 城墙内街区 -->
|
||||||
|
<rect x="1030" y="980" width="540" height="340" fill="#eadfc2" opacity="0.8" />
|
||||||
|
<!-- 高新区 -->
|
||||||
|
<rect x="640" y="900" width="360" height="700" rx="30" fill="#e3e6d0" opacity="0.5" />
|
||||||
|
<!-- 曲江新区 -->
|
||||||
|
<rect x="1750" y="1550" width="640" height="480" rx="40" fill="#e0e8d2" opacity="0.5" />
|
||||||
|
<!-- 临潼片区 -->
|
||||||
|
<rect x="2420" y="150" width="540" height="560" rx="40" fill="#ece0c2" opacity="0.5" />
|
||||||
|
|
||||||
|
<!-- ═══ 绿地 / 遗址公园 ═══ -->
|
||||||
|
<!-- 大明宫遗址公园 -->
|
||||||
|
<rect x="1210" y="690" width="240" height="180" rx="24" fill="#d8e3c0" opacity="0.9" />
|
||||||
|
<text x="1330" y="700" font-family="Kaiti SC, KaiTi, serif" font-size="34" fill="#7a8a5e" text-anchor="middle" opacity="0.85">大明宫遗址公园</text>
|
||||||
|
<!-- 兴庆宫公园 -->
|
||||||
|
<rect x="1690" y="840" width="220" height="150" rx="24" fill="#d8e3c0" opacity="0.85" />
|
||||||
|
<text x="1800" y="850" font-family="Kaiti SC, KaiTi, serif" font-size="30" fill="#7a8a5e" text-anchor="middle" opacity="0.85">兴庆宫公园</text>
|
||||||
|
|
||||||
|
<!-- ═══ 水系 ═══ -->
|
||||||
|
<!-- 渭河(北部横贯) -->
|
||||||
|
<path d="M -20 215 C 400 245, 700 190, 1000 218 C 1300 245, 1600 172, 1900 202 C 2200 232, 2600 180, 3020 208"
|
||||||
|
fill="none" stroke="url(#water)" stroke-width="34" stroke-linecap="round" opacity="0.9" />
|
||||||
|
<path d="M -20 215 C 400 245, 700 190, 1000 218 C 1300 245, 1600 172, 1900 202 C 2200 232, 2600 180, 3020 208"
|
||||||
|
fill="none" stroke="#e8f0f4" stroke-width="3" stroke-dasharray="2 26" stroke-linecap="round" opacity="0.8" />
|
||||||
|
<text x="700" y="185" font-family="Kaiti SC, KaiTi, serif" font-size="40" fill="#7fa3bb" text-anchor="middle">渭 河</text>
|
||||||
|
|
||||||
|
<!-- 灞河(东北纵贯) -->
|
||||||
|
<path d="M 2330 2120 C 2310 1800, 2360 1500, 2330 1200 C 2300 900, 2380 500, 2348 218"
|
||||||
|
fill="none" stroke="url(#water)" stroke-width="18" stroke-linecap="round" opacity="0.85" />
|
||||||
|
<text x="2296" y="960" font-family="Kaiti SC, KaiTi, serif" font-size="32" fill="#7fa3bb" text-anchor="middle" transform="rotate(-88 2296 960)">灞河</text>
|
||||||
|
|
||||||
|
<!-- 浐河 -->
|
||||||
|
<path d="M 2090 2120 C 2070 1800, 2110 1500, 2080 1200 C 2050 900, 2105 450, 2078 212"
|
||||||
|
fill="none" stroke="url(#water)" stroke-width="12" stroke-linecap="round" opacity="0.8" />
|
||||||
|
<text x="2118" y="1250" font-family="Kaiti SC, KaiTi, serif" font-size="30" fill="#7fa3bb" text-anchor="middle" transform="rotate(-88 2118 1250)">浐河</text>
|
||||||
|
|
||||||
|
<!-- 沣河(西南) -->
|
||||||
|
<path d="M 460 2120 C 440 1800, 480 1400, 450 1000 C 430 700, 462 400, 438 248"
|
||||||
|
fill="none" stroke="url(#water)" stroke-width="15" stroke-linecap="round" opacity="0.8" />
|
||||||
|
<text x="496" y="900" font-family="Kaiti SC, KaiTi, serif" font-size="32" fill="#7fa3bb" text-anchor="middle" transform="rotate(-88 496 900)">沣河</text>
|
||||||
|
|
||||||
|
<!-- 曲江池遗址水泊 -->
|
||||||
|
<ellipse cx="2215" cy="1965" rx="225" ry="82" fill="url(#water)" opacity="0.85" />
|
||||||
|
<text x="2215" y="2048" font-family="Kaiti SC, KaiTi, serif" font-size="30" fill="#7fa3bb" text-anchor="middle" opacity="0.9">曲江池</text>
|
||||||
|
|
||||||
|
<!-- ═══ 二环(虚线示意) ═══ -->
|
||||||
|
<rect x="620" y="520" width="1760" height="1200" rx="90" fill="none"
|
||||||
|
stroke="#b08a5e" stroke-width="9" stroke-dasharray="30 22" opacity="0.55" />
|
||||||
|
|
||||||
|
<!-- ═══ 路网(主干道,棕褐手账线条) ═══ -->
|
||||||
|
<g stroke="#b08a5e" stroke-linecap="round" fill="none" opacity="0.85">
|
||||||
|
<!-- 东西五路(城北,东西贯通) -->
|
||||||
|
<path d="M 320 940 C 700 930, 900 945, 1030 940 M 1570 940 C 1800 945, 2100 930, 2380 935" stroke-width="14" />
|
||||||
|
<!-- 东西大街(城内中轴 + 城外延伸) -->
|
||||||
|
<path d="M 320 1150 C 620 1140, 860 1155, 1030 1150 M 1570 1150 C 1800 1155, 2020 1140, 2280 1150" stroke-width="16" />
|
||||||
|
<!-- 解放路(城内南北 + 北延至大明宫) -->
|
||||||
|
<path d="M 1520 1320 L 1520 980 M 1520 980 C 1490 880, 1470 810, 1450 740" stroke-width="12" />
|
||||||
|
<!-- 朱雀大街(南门向南) -->
|
||||||
|
<path d="M 1180 1320 C 1180 1480, 1190 1640, 1185 1760" stroke-width="13" />
|
||||||
|
<!-- 长安路中轴(南门直下接雁塔路弧线) -->
|
||||||
|
<path d="M 1300 1320 C 1300 1440, 1320 1560, 1380 1660 C 1500 1760, 1720 1720, 1900 1660" stroke-width="15" />
|
||||||
|
<!-- 高新路(西南纵贯) -->
|
||||||
|
<path d="M 820 900 C 810 1150, 830 1450, 815 1720" stroke-width="12" opacity="0.7" />
|
||||||
|
<!-- 城内棋盘细街 -->
|
||||||
|
<g stroke-width="5" opacity="0.45">
|
||||||
|
<path d="M 1100 990 L 1100 1310" />
|
||||||
|
<path d="M 1220 990 L 1220 1310" />
|
||||||
|
<path d="M 1420 990 L 1420 1310" />
|
||||||
|
<path d="M 1040 1060 L 1560 1060" />
|
||||||
|
<path d="M 1040 1240 L 1560 1240" />
|
||||||
|
</g>
|
||||||
|
<!-- 连霍高速(东北去临潼,虚线) -->
|
||||||
|
<path d="M 2380 720 C 2450 650, 2490 570, 2560 470 C 2650 375, 2750 315, 2845 240"
|
||||||
|
stroke-width="10" stroke-dasharray="26 18" opacity="0.5" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ═══ 明城墙 ═══ -->
|
||||||
|
<!-- 护城河 -->
|
||||||
|
<rect x="1006" y="956" width="588" height="388" rx="26" fill="none"
|
||||||
|
stroke="url(#water)" stroke-width="13" opacity="0.85" />
|
||||||
|
<!-- 墙体 -->
|
||||||
|
<rect x="1030" y="980" width="540" height="340" rx="14" fill="url(#wall)"
|
||||||
|
stroke="#8a6a42" stroke-width="13" />
|
||||||
|
<!-- 城门(四边中点开口,白段覆盖) -->
|
||||||
|
<g stroke="#eadfc2" stroke-width="17">
|
||||||
|
<path d="M 1272 980 L 1328 980" />
|
||||||
|
<path d="M 1272 1320 L 1328 1320" />
|
||||||
|
<path d="M 1030 1122 L 1030 1178" />
|
||||||
|
<path d="M 1570 1122 L 1570 1178" />
|
||||||
|
</g>
|
||||||
|
<!-- 城门小楼 -->
|
||||||
|
<g fill="#c9a875" stroke="#8a6a42" stroke-width="3">
|
||||||
|
<rect x="1262" y="966" width="76" height="28" rx="6" />
|
||||||
|
<rect x="1262" y="1306" width="76" height="28" rx="6" />
|
||||||
|
<rect x="1016" y="1108" width="28" height="84" rx="6" />
|
||||||
|
<rect x="1556" y="1108" width="28" height="84" rx="6" />
|
||||||
|
</g>
|
||||||
|
<text x="1300" y="948" font-family="Kaiti SC, KaiTi, serif" font-size="34" fill="#8a6a42" text-anchor="middle" opacity="0.9">明城墙</text>
|
||||||
|
|
||||||
|
<!-- ═══ 区域地名标注 ═══ -->
|
||||||
|
<g font-family="Kaiti SC, KaiTi, serif" fill="#9a8a6e" opacity="0.75" text-anchor="middle">
|
||||||
|
<text x="2680" y="640" font-size="38">临 潼</text>
|
||||||
|
<text x="2100" y="1610" font-size="36">曲江新区</text>
|
||||||
|
<text x="700" y="1380" font-size="36">高新区</text>
|
||||||
|
<text x="1890" y="520" font-size="34">灞桥</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ═══ 罗盘(右下装饰) ═══ -->
|
||||||
|
<g transform="translate(2760, 1900)" opacity="0.9">
|
||||||
|
<circle r="86" fill="#f0e6cc" stroke="#8a6a42" stroke-width="6" />
|
||||||
|
<circle r="70" fill="none" stroke="#b08a5e" stroke-width="2.5" stroke-dasharray="5 7" />
|
||||||
|
<!-- 四芒星指针 -->
|
||||||
|
<path d="M 0 -62 L 12 -12 L 62 0 L 12 12 L 0 62 L -12 12 L -62 0 L -12 -12 Z" fill="#c9a875" stroke="#8a6a42" stroke-width="2" />
|
||||||
|
<path d="M 0 -62 L 12 -12 L -12 -12 Z" fill="#8a6a42" />
|
||||||
|
<text y="-88" font-family="Kaiti SC, KaiTi, serif" font-size="30" fill="#8a6a42" text-anchor="middle">北</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ═══ 地图标题(左上) ═══ -->
|
||||||
|
<g transform="translate(110, 150)">
|
||||||
|
<text font-family="Kaiti SC, KaiTi, serif" font-size="72" fill="#6b4e2e">西安 · 旅行手账地图</text>
|
||||||
|
<text x="8" y="52" font-family="Georgia, serif" font-size="24" fill="#b08a5e" letter-spacing="6">XI'AN TRAVEL JOURNAL MAP</text>
|
||||||
|
<!-- 手绘下划线 -->
|
||||||
|
<path d="M 0 78 C 200 70, 500 86, 760 74" fill="none" stroke="#c9a875" stroke-width="5" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ═══ 底部提示 ═══ -->
|
||||||
|
<text x="1500" y="2065" font-family="Kaiti SC, KaiTi, serif" font-size="28" fill="#b08a5e" text-anchor="middle" opacity="0.8">—— 走进景点,翻开你的旅行手账 ——</text>
|
||||||
|
|
||||||
|
<!-- ═══ 装饰边框 ═══ -->
|
||||||
|
<rect x="26" y="26" width="2948" height="2048" fill="none" stroke="#c9b488" stroke-width="5" />
|
||||||
|
<rect x="40" y="40" width="2920" height="2020" fill="none" stroke="#c9b488" stroke-width="2" opacity="0.7" />
|
||||||
|
<!-- 四角装饰 -->
|
||||||
|
<g fill="#c9a875">
|
||||||
|
<path d="M 26 106 L 46 26 L 106 26 L 86 106 Z" opacity="0.6" />
|
||||||
|
<path d="M 2974 106 L 2954 26 L 2894 26 L 2914 106 Z" opacity="0.6" />
|
||||||
|
<path d="M 26 1994 L 46 2074 L 106 2074 L 86 1994 Z" opacity="0.6" />
|
||||||
|
<path d="M 2974 1994 L 2954 2074 L 2894 2074 L 2914 1994 Z" opacity="0.6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(107 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(131 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">⚔️</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">一号坑的军阵全景</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 928 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(107 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(131 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">⚔️</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">跪射俑的侧脸</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 922 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(114 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(138 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🏮</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">华灯初上的步行街</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(114 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(138 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🏮</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">不倒翁小姐姐</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 920 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(25 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(49 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🧱</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">城墙上骑行的傍晚</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 924 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(91 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(115 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🏛️</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">含元殿的巨大夯土台</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 931 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(198 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(222 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🗼</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">夕阳下的大雁塔</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 923 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(198 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(222 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🗼</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">音乐喷泉开场的瞬间</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 929 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(127 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(151 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">♨️</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">骊山脚下的温泉古苑</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 931 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(32 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(56 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🥟</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">滋滋冒油的烤肉串</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 924 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(32 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(56 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🥟</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">排长队的甑糕摊</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(194 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(218 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🏺</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">排队两小时的入口</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(194 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(218 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🏺</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">唐代鎏金舞马衔杯银壶</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 932 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(284 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(308 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🛕</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">晨钟里的古塔剪影</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(16 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(40 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="900" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="1128" height="828" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="600" y="406" font-size="150" text-anchor="middle">🏯</text>
|
||||||
|
<text x="600" y="542" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">夜晚的钟楼灯火辉煌</text>
|
||||||
|
<text x="600" y="608" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview 占位照片生成脚本。
|
||||||
|
*
|
||||||
|
* 文件用途:
|
||||||
|
* - 依据 src/data/spots.js 的照片清单,批量生成 4:3 手账风占位 SVG,
|
||||||
|
* 供照片未就绪期的开发与演示使用。
|
||||||
|
*
|
||||||
|
* 核心职责:
|
||||||
|
* 1. 读取景点配置,为每张照片生成 `public/assets/photos/<id>_<序号>.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 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="hsl(${hue} 38% 78%)" />
|
||||||
|
<stop offset="1" stop-color="hsl(${(hue + 24) % 360} 30% 64%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#bg)" />
|
||||||
|
<rect x="36" y="36" width="${W - 72}" height="${H - 72}" fill="none"
|
||||||
|
stroke="rgba(255,250,235,0.7)" stroke-width="4" stroke-dasharray="18 12" rx="10" />
|
||||||
|
<text x="${W / 2}" y="${H / 2 - 44}" font-size="150" text-anchor="middle">${icon}</text>
|
||||||
|
<text x="${W / 2}" y="${H / 2 + 92}" font-family="Kaiti SC, KaiTi, serif" font-size="64"
|
||||||
|
fill="rgba(70,50,28,0.82)" text-anchor="middle">${escapedCaption}</text>
|
||||||
|
<text x="${W / 2}" y="${H / 2 + 158}" font-family="Kaiti SC, KaiTi, serif" font-size="36"
|
||||||
|
fill="rgba(70,50,28,0.55)" text-anchor="middle" letter-spacing="8">· 占位照片 ·</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脚本入口:遍历景点生成占位照片。
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>} 完成后输出汇总。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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<Object>} 景点配置数组。
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
@@ -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<string, Set<Function>>} 事件名 → 监听器集合。 */
|
||||||
|
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();
|
||||||
@@ -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<string>} 当前已解锁景点 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, PoiMarker>} 景点 id → 标记。 */
|
||||||
|
this.markers = new Map();
|
||||||
|
/** @type {Set<string>} 处于冷却(刚看过、需离开才能重触发)的景点 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="co-paper">
|
||||||
|
<div class="co-content">
|
||||||
|
<h1 class="co-title">🎉 旅行完成</h1>
|
||||||
|
<p class="co-subtitle">这一程,长安的所有回忆都被你收进了手账</p>
|
||||||
|
<div class="co-stats">
|
||||||
|
<div class="co-stat"><b id="co-days">0</b><span>天旅程</span></div>
|
||||||
|
<div class="co-stat"><b id="co-spots">0</b><span>个回忆</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="co-grid"></div>
|
||||||
|
<button class="btn-primary co-continue" type="button">继续漫游长安</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="co-photo">${spot.icon}</div>
|
||||||
|
<div class="co-name">${spot.name}</div>
|
||||||
|
<div class="co-date">${spot.date}</div>
|
||||||
|
`;
|
||||||
|
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', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<span class="hud-icon">🗺️</span>
|
||||||
|
<span class="hud-count">0 / 0</span>
|
||||||
|
`;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="pm-backdrop"></div>
|
||||||
|
<div class="pm-stage">
|
||||||
|
<header class="pm-header">
|
||||||
|
<span class="pm-name"></span>
|
||||||
|
<span class="pm-badge" hidden>🌟 新解锁</span>
|
||||||
|
<span class="pm-count"></span>
|
||||||
|
</header>
|
||||||
|
<div class="pm-stack"></div>
|
||||||
|
<p class="pm-story"></p>
|
||||||
|
<p class="pm-hint">点击照片翻看下一张 · 点击空白处关闭</p>
|
||||||
|
</div>
|
||||||
|
<button class="pm-close" type="button" aria-label="关闭">✕</button>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="pl-photo">
|
||||||
|
<img src="${photos[photoIdx].src}" alt="${photos[photoIdx].caption}" />
|
||||||
|
</div>
|
||||||
|
<figcaption class="pl-caption">${photos[photoIdx].caption}</figcaption>
|
||||||
|
<span class="pl-date">${date}</span>
|
||||||
|
`;
|
||||||
|
// 照片缺失降级为"照片待补"占位。
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="to-paper">
|
||||||
|
<div class="to-deco to-tape-1"></div>
|
||||||
|
<div class="to-deco to-tape-2"></div>
|
||||||
|
<div class="to-content">
|
||||||
|
<div class="to-polaroid">
|
||||||
|
<div class="to-cover-photo">🗺️</div>
|
||||||
|
<figcaption class="to-cover-caption">国庆 · 长安行</figcaption>
|
||||||
|
</div>
|
||||||
|
<h1 class="to-title">西安旅行记忆</h1>
|
||||||
|
<p class="to-subtitle">一座可以走进去的旅行手账</p>
|
||||||
|
<div class="to-progress" hidden></div>
|
||||||
|
<button class="btn-primary to-start" type="button">开始旅行</button>
|
||||||
|
<button class="btn-ghost to-reset" type="button" hidden>重新开始</button>
|
||||||
|
<p class="to-foot">走进地图上的景点,翻开属于你的拍立得回忆</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||