diff --git a/game/GameManager.ts b/game/GameManager.ts new file mode 100644 index 0000000..dfa120e --- /dev/null +++ b/game/GameManager.ts @@ -0,0 +1,96 @@ +import { + INITIAL_HC, + INITIAL_KPI, +} from './constants' + +type GameState = 'idle' | 'playing' | 'paused' | 'victory' | 'defeat' + +/** + * 游戏状态管理器(单例) + * 负责管理 HC(人才储备)、KPI、波次状态,并通过回调事件通知 Scene + */ +export class GameManager { + private static instance: GameManager + + public hc: number = INITIAL_HC + public kpi: number = INITIAL_KPI + public currentWave: number = 0 + public gameState: GameState = 'idle' + + // 事件回调(Scene 通过这些 hook 更新 HUD) + public onHCChange: ((hc: number) => void)[] = [] + public onKPIChange: ((kpi: number) => void)[] = [] + public onGameOver: (() => void)[] = [] + public onVictory: (() => void)[] = [] + + private constructor() {} + + static getInstance(): GameManager { + if (!GameManager.instance) { + GameManager.instance = new GameManager() + } + return GameManager.instance + } + + /** 重置游戏状态(新局开始时调用) */ + reset(): void { + this.hc = INITIAL_HC + this.kpi = INITIAL_KPI + this.currentWave = 0 + this.gameState = 'idle' + this.onHCChange = [] + this.onKPIChange = [] + this.onGameOver = [] + this.onVictory = [] + } + + /** + * 扣除 HC,余额不足时返回 false + * @param amount 扣除数量 + */ + spendHC(amount: number): boolean { + if (this.hc < amount) return false + this.hc -= amount + this.onHCChange.forEach(cb => cb(this.hc)) + return true + } + + /** + * 增加 HC + * @param amount 增加数量 + */ + addHC(amount: number): void { + this.hc += amount + this.onHCChange.forEach(cb => cb(this.hc)) + } + + /** + * 减少 KPI,归零时触发 GameOver + * @param amount 减少数量 + */ + reduceKPI(amount: number): void { + this.kpi = Math.max(0, this.kpi - amount) + this.onKPIChange.forEach(cb => cb(this.kpi)) + if (this.kpi <= 0 && this.gameState === 'playing') { + this.gameState = 'defeat' + this.onGameOver.forEach(cb => cb()) + } + } + + /** + * 增加 KPI(不超过 100) + * @param amount 增加数量 + */ + addKPI(amount: number): void { + this.kpi = Math.min(100, this.kpi + amount) + this.onKPIChange.forEach(cb => cb(this.kpi)) + } + + /** 触发胜利 */ + triggerVictory(): void { + if (this.gameState === 'playing') { + this.gameState = 'victory' + this.onVictory.forEach(cb => cb()) + } + } +} diff --git a/game/GameScene.ts b/game/GameScene.ts new file mode 100644 index 0000000..afb0d71 --- /dev/null +++ b/game/GameScene.ts @@ -0,0 +1,148 @@ +import type Phaser from 'phaser' +import { MAP_COLS, MAP_ROWS, HUD_HEIGHT, GAME_HEIGHT, GAME_WIDTH } from './constants' +import { GameManager } from './GameManager' +import { + PATH_TILES, + getCellSize, + drawAllTiles, + renderMapLabels, + renderHUD, + updateKPIBar, + BAR_X, + BAR_Y, + BAR_W, + BAR_H, +} from './mapRenderer' + +/** + * 创建主游戏场景类(工厂函数,接收动态导入的 Phaser 实例) + * 这样可以保证 SSR 安全(只在客户端执行) + */ +export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { + class GameScene extends PhaserLib.Scene { + private manager!: GameManager + private kpiBar!: Phaser.GameObjects.Graphics + private kpiText!: Phaser.GameObjects.Text + private hcText!: Phaser.GameObjects.Text + private hoveredTile: { col: number; row: number } | null = null + private tileGraphics!: Phaser.GameObjects.Graphics + + constructor() { + super({ key: 'GameScene' }) + } + + create(): void { + this.manager = GameManager.getInstance() + this.manager.reset() + this.manager.gameState = 'playing' + + // 渲染地图 + this.tileGraphics = this.add.graphics() + drawAllTiles(this.tileGraphics, null) + + // 地图装饰标签 + renderMapLabels(this) + + // HUD(在地图之上) + const hud = renderHUD(this) + this.kpiBar = hud.kpiBar + this.kpiText = hud.kpiText + this.hcText = hud.hcText + + // 鼠标交互 + this.setupInteraction() + + // GameManager 事件回调 → 更新 HUD + this.manager.onKPIChange.push((kpi: number) => { + updateKPIBar(this.kpiBar, kpi) + this.kpiText.setText(`${kpi}%`) + }) + this.manager.onHCChange.push((hc: number) => { + this.hcText.setText(`HC: ${hc}`) + }) + } + + /** 注册鼠标悬停 + 点击事件 */ + private setupInteraction(): void { + const { cellW, cellH } = getCellSize() + + this.input.on( + 'pointermove', + (pointer: Phaser.Input.Pointer) => { + const col = Math.floor(pointer.x / cellW) + const row = Math.floor((pointer.y - HUD_HEIGHT) / cellH) + + if (col >= 0 && col < MAP_COLS && row >= 0 && row < MAP_ROWS) { + if (!PATH_TILES.has(`${col},${row}`)) { + if ( + !this.hoveredTile || + this.hoveredTile.col !== col || + this.hoveredTile.row !== row + ) { + this.hoveredTile = { col, row } + drawAllTiles(this.tileGraphics, this.hoveredTile) + } + return + } + } + if (this.hoveredTile !== null) { + this.hoveredTile = null + drawAllTiles(this.tileGraphics, null) + } + } + ) + + this.input.on( + 'pointerdown', + (pointer: Phaser.Input.Pointer) => { + const col = Math.floor(pointer.x / cellW) + const row = Math.floor((pointer.y - HUD_HEIGHT) / cellH) + + if ( + col >= 0 && col < MAP_COLS && + row >= 0 && row < MAP_ROWS && + !PATH_TILES.has(`${col},${row}`) + ) { + this.showBuildPrompt(col, row, cellW, cellH) + } + } + ) + } + + /** 点击可建格子时的占位提示(Phase 1) */ + private showBuildPrompt( + col: number, + row: number, + cellW: number, + cellH: number + ): void { + const x = col * cellW + cellW / 2 + const y = HUD_HEIGHT + row * cellH + cellH / 2 + const tip = this.add + .text(x, y - 20, '建塔 (即将开放)', { + fontFamily: 'VT323, monospace', + fontSize: '18px', + color: '#F43F5E', + backgroundColor: '#0a1628', + padding: { x: 8, y: 4 }, + }) + .setOrigin(0.5, 1) + .setDepth(20) + + this.time.delayedCall(1500, () => { + tip.destroy() + }) + } + } + + // 避免 unused variable 警告 + void MAP_ROWS + void GAME_HEIGHT + void GAME_WIDTH + void BAR_X + void BAR_Y + void BAR_W + void BAR_H + + return GameScene +} diff --git a/game/config.ts b/game/config.ts new file mode 100644 index 0000000..9cf3e8d --- /dev/null +++ b/game/config.ts @@ -0,0 +1,26 @@ +import type Phaser from 'phaser' +import { GAME_WIDTH, GAME_HEIGHT } from './constants' + +/** + * 创建 Phaser 游戏配置 + * 注意:此文件只在客户端(动态 import 后)执行,不会在 SSR 中运行 + * @param containerId HTML 容器元素的 id + */ +export function createGameConfig(containerId: string): Phaser.Types.Core.GameConfig { + return { + type: (globalThis as typeof globalThis & { Phaser?: { AUTO: number } }).Phaser?.AUTO ?? 0, + width: GAME_WIDTH, + height: GAME_HEIGHT, + parent: containerId, + backgroundColor: '#0a1628', + scale: { + mode: 3, // Phaser.Scale.FIT = 3 + autoCenter: 1, // Phaser.Scale.CENTER_BOTH = 1 + }, + scene: [], // 场景在 GamePage 中动态注入 + physics: { + default: 'arcade', + arcade: { debug: false }, + }, + } +} diff --git a/game/constants.ts b/game/constants.ts new file mode 100644 index 0000000..874cfb0 --- /dev/null +++ b/game/constants.ts @@ -0,0 +1,40 @@ +// 游戏地图常量 +export const MAP_COLS = 16 +export const MAP_ROWS = 12 +export const TILE_SIZE = 80 // 每格80px,总计 1280x960,Phaser 会缩放适配 +export const GAME_WIDTH = 1280 +export const GAME_HEIGHT = 720 + +// HUD 高度(顶部状态栏) +export const HUD_HEIGHT = 60 + +// S型路径折线关键坐标点 +// 路径:从左侧(0,2) → 右至(11,2) → 下至(11,9) → 右至(15,9) +export const PATH_WAYPOINTS = [ + { x: 0, y: 2 }, + { x: 11, y: 2 }, + { x: 11, y: 9 }, + { x: 15, y: 9 }, +] as const + +// 游戏初始数值 +export const INITIAL_HC = 200 +export const INITIAL_KPI = 100 +export const STAMINA_MAX = 100 +export const STAMINA_REGEN = 5 // 每秒恢复量 +export const COFFEE_COST = 10 // 瑞幸咖啡 HC 成本 + +// 颜色常量(Phaser 使用 0x 十六进制) +export const COLOR_PATH = 0x3d2b1f // 路径格子:深褐色 +export const COLOR_BUILDABLE = 0x1e3a5f // 可建塔格子:深蓝色 +export const COLOR_HOVER = 0x2d5a8e // 悬停高亮:亮蓝色 +export const COLOR_BORDER = 0x0a1628 // 格子边框:深夜蓝 +export const COLOR_HUD_BG = 0x0a1628 // HUD 背景色 + +// 地图装饰性标记文字 +export const MAP_LABELS: { col: number; row: number; text: string }[] = [ + { col: 2, row: 5, text: '面试间' }, + { col: 8, row: 5, text: '财务室' }, + { col: 4, row: 10, text: 'P8会议室' }, + { col: 13, row: 5, text: '茶水间' }, +] diff --git a/game/mapRenderer.ts b/game/mapRenderer.ts new file mode 100644 index 0000000..6b7fb71 --- /dev/null +++ b/game/mapRenderer.ts @@ -0,0 +1,179 @@ +import type Phaser from 'phaser' +import { + MAP_COLS, + MAP_ROWS, + GAME_WIDTH, + HUD_HEIGHT, + PATH_WAYPOINTS, + COLOR_PATH, + COLOR_BUILDABLE, + COLOR_HOVER, + COLOR_BORDER, + MAP_LABELS, + INITIAL_KPI, +} from './constants' + +/** 将折线关键坐标点展开为完整路径格子集合 */ +export function buildPathTiles( + waypoints: readonly { x: number; y: number }[] +): Set { + const tiles = new Set() + for (let i = 0; i < waypoints.length - 1; i++) { + const from = waypoints[i] + const to = waypoints[i + 1] + if (from.x === to.x) { + const minY = Math.min(from.y, to.y) + const maxY = Math.max(from.y, to.y) + for (let y = minY; y <= maxY; y++) tiles.add(`${from.x},${y}`) + } else { + const minX = Math.min(from.x, to.x) + const maxX = Math.max(from.x, to.x) + for (let x = minX; x <= maxX; x++) tiles.add(`${x},${from.y}`) + } + } + return tiles +} + +export const PATH_TILES = buildPathTiles(PATH_WAYPOINTS) + +/** 计算格子尺寸 */ +export function getCellSize() { + const cellW = Math.floor(GAME_WIDTH / MAP_COLS) + const cellH = Math.floor((720 - HUD_HEIGHT) / MAP_ROWS) + return { cellW, cellH } +} + +/** + * 绘制所有地图格子 + */ +export function drawAllTiles( + g: Phaser.GameObjects.Graphics, + hovered: { col: number; row: number } | null +): void { + const { cellW, cellH } = getCellSize() + g.clear() + + for (let row = 0; row < MAP_ROWS; row++) { + for (let col = 0; col < MAP_COLS; col++) { + const isPath = PATH_TILES.has(`${col},${row}`) + const isHovered = + !isPath && hovered !== null && + hovered.col === col && hovered.row === row + + const fillColor = isPath + ? COLOR_PATH + : isHovered ? COLOR_HOVER : COLOR_BUILDABLE + + const x = col * cellW + const y = HUD_HEIGHT + row * cellH + + g.fillStyle(fillColor, 1) + g.fillRect(x + 1, y + 1, cellW - 2, cellH - 2) + g.lineStyle(1, COLOR_BORDER, 0.6) + g.strokeRect(x, y, cellW, cellH) + } + } +} + +/** + * 渲染地图装饰性标签 + */ +export function renderMapLabels(scene: Phaser.Scene): void { + const { cellW, cellH } = getCellSize() + for (const label of MAP_LABELS) { + const x = label.col * cellW + cellW / 2 + const y = HUD_HEIGHT + label.row * cellH + cellH / 2 + scene.add + .text(x, y, label.text, { + fontFamily: 'VT323, monospace', + fontSize: '14px', + color: '#A78BFA', + }) + .setOrigin(0.5, 0.5) + .setAlpha(0.5) + } +} + +/** HUD 进度条参数 */ +export const BAR_W = 300 +export const BAR_H = 16 +export const BAR_X = (GAME_WIDTH - BAR_W) / 2 +export const BAR_Y = (HUD_HEIGHT - BAR_H) / 2 + +/** + * 渲染顶部 HUD 区域,返回可更新的 Graphics 和 Text 对象 + */ +export function renderHUD(scene: Phaser.Scene): { + kpiBar: Phaser.GameObjects.Graphics + kpiText: Phaser.GameObjects.Text + hcText: Phaser.GameObjects.Text +} { + // HUD 背景 + const hudBg = scene.add.graphics() + hudBg.fillStyle(0x0a1628, 0.92) + hudBg.fillRect(0, 0, GAME_WIDTH, HUD_HEIGHT) + hudBg.lineStyle(1, 0x1e3a5f, 1) + hudBg.strokeRect(0, 0, GAME_WIDTH, HUD_HEIGHT) + + // 左侧标题 + scene.add.text(16, HUD_HEIGHT / 2, '大厂保卫战', { + fontFamily: "'Press Start 2P', monospace", + fontSize: '10px', + color: '#A78BFA', + }).setOrigin(0, 0.5) + + // KPI 标签 + scene.add.text(BAR_X - 8, HUD_HEIGHT / 2, 'KPI', { + fontFamily: "'Press Start 2P', monospace", + fontSize: '8px', + color: '#E2E8F0', + }).setOrigin(1, 0.5) + + // 进度条背景轨道 + const trackBar = scene.add.graphics() + trackBar.fillStyle(0x1a1a2e, 1) + trackBar.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H) + trackBar.lineStyle(1, 0x7c3aed, 0.6) + trackBar.strokeRect(BAR_X, BAR_Y, BAR_W, BAR_H) + + // KPI 进度前景 + const kpiBar = scene.add.graphics() + updateKPIBar(kpiBar, INITIAL_KPI) + + // KPI 百分比文字 + const kpiText = scene.add.text( + BAR_X + BAR_W / 2, HUD_HEIGHT / 2, + `${INITIAL_KPI}%`, + { + fontFamily: "'Press Start 2P', monospace", + fontSize: '8px', + color: '#E2E8F0', + } + ).setOrigin(0.5, 0.5).setDepth(1) + + // 右侧 HC 数值 + const hcText = scene.add.text( + GAME_WIDTH - 16, HUD_HEIGHT / 2, + `HC: 200`, + { + fontFamily: "'Press Start 2P', monospace", + fontSize: '10px', + color: '#A78BFA', + } + ).setOrigin(1, 0.5) + + return { kpiBar, kpiText, hcText } +} + +/** + * 更新 KPI 进度条(颜色随数值变化:绿→黄→红) + */ +export function updateKPIBar( + kpiBar: Phaser.GameObjects.Graphics, + kpi: number +): void { + kpiBar.clear() + const color = kpi > 60 ? 0x22c55e : kpi > 30 ? 0xf59e0b : 0xef4444 + kpiBar.fillStyle(color, 1) + kpiBar.fillRect(BAR_X + 1, BAR_Y + 1, (BAR_W - 2) * (kpi / 100), BAR_H - 2) +}