diff --git a/game/enemies/BossVP.ts b/game/enemies/BossVP.ts new file mode 100644 index 0000000..7bd3cbd --- /dev/null +++ b/game/enemies/BossVP.ts @@ -0,0 +1,113 @@ +import type Phaser from 'phaser' +import { EnemyBase, type PathPoint } from './EnemyBase' + +const QUOTES = [ + '我来教大家怎么做事', + '你们缺乏战略眼光', + '这不是执行力的问题', +] + +export class BossVP extends EnemyBase { + private skillTimer: number = 20000 + private onDestroyTower?: () => void + + constructor( + scene: Phaser.Scene, + pathPoints: PathPoint[], + onDestroyTower?: () => void + ) { + super(scene, pathPoints, 800, 40, 30, 150) + this.onDestroyTower = onDestroyTower + this.drawSprite() + // BOSS 出现时全屏红色闪光 + scene.cameras.main.flash(800, 255, 0, 0, false) + this.showBossAlert() + } + + private showBossAlert(): void { + const alert = this.scene.add + .text(640, 360, '⚠ 空降VP来袭!⚠', { + fontFamily: 'VT323, monospace', + fontSize: '36px', + color: '#FBBF24', + backgroundColor: '#7F1D1D', + padding: { x: 16, y: 8 }, + }) + .setOrigin(0.5, 0.5) + .setDepth(50) + this.scene.tweens.add({ + targets: alert, + alpha: 0, + duration: 2500, + delay: 500, + onComplete: () => alert.destroy(), + }) + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 金色六边形 + this.sprite.fillStyle(0xfbbf24, 1) + const r = 22 + this.sprite.fillPoints(this.hexPoints(r), true) + // 金色外框 + this.sprite.lineStyle(3, 0xf59e0b, 1) + this.sprite.strokePoints(this.hexPoints(r + 4), false) + // 内部颜色 + this.sprite.fillStyle(0xd97706, 1) + this.sprite.fillCircle(0, 0, 8) + this.sprite.setDepth(12) + this.sprite.setPosition(this.x, this.y) + } + + private hexPoints(r: number): Phaser.Types.Math.Vector2Like[] { + const pts: Phaser.Types.Math.Vector2Like[] = [] + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + pts.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r }) + } + return pts + } + + override update(delta: number): void { + if (this.isDead) return + super.update(delta) + + this.skillTimer -= delta + if (this.skillTimer <= 0) { + this.skillTimer = 20000 + this.triggerOrgRestructure() + } + // 重绘六边形到新位置 + this.drawSprite() + } + + private triggerOrgRestructure(): void { + // 组织架构调整:随机摧毁一个防御塔 + if (this.onDestroyTower) { + this.onDestroyTower() + } + const txt = this.scene.add + .text(this.x, this.y - 40, '组织架构调整!', { + fontFamily: 'VT323, monospace', + fontSize: '18px', + color: '#FBBF24', + backgroundColor: '#7C2D12', + padding: { x: 6, y: 3 }, + }) + .setOrigin(0.5, 1) + .setDepth(25) + this.scene.tweens.add({ + targets: txt, + y: this.y - 70, + alpha: 0, + duration: 2000, + onComplete: () => txt.destroy(), + }) + } + + getQuote(): string { + return QUOTES[Math.floor(Math.random() * QUOTES.length)] + } +} diff --git a/game/enemies/EnemyBase.ts b/game/enemies/EnemyBase.ts new file mode 100644 index 0000000..c533cd4 --- /dev/null +++ b/game/enemies/EnemyBase.ts @@ -0,0 +1,249 @@ +import type Phaser from 'phaser' +import { GameManager } from '../GameManager' +import { TILE_SIZE, HUD_HEIGHT, PATH_WAYPOINTS } from '../constants' + +export interface PathPoint { + x: number + y: number +} + +/** 将格子坐标转换为像素坐标(格子中心) */ +function gridToPixel(gx: number, gy: number): PathPoint { + return { + x: gx * TILE_SIZE + TILE_SIZE / 2, + y: gy * TILE_SIZE + TILE_SIZE / 2 + HUD_HEIGHT, + } +} + +/** 将 PATH_WAYPOINTS 扩展为完整转折路径(去重相邻相同点) */ +export function buildFullPath(): PathPoint[] { + const points: PathPoint[] = [] + for (let i = 0; i < PATH_WAYPOINTS.length - 1; i++) { + const from = gridToPixel(PATH_WAYPOINTS[i].x, PATH_WAYPOINTS[i].y) + const to = gridToPixel(PATH_WAYPOINTS[i + 1].x, PATH_WAYPOINTS[i + 1].y) + points.push(from) + points.push(to) + } + return points.filter( + (p, i, arr) => i === 0 || p.x !== arr[i - 1].x || p.y !== arr[i - 1].y + ) +} + +export interface DotEffect { + damage: number + duration: number + timer: number +} + +export abstract class EnemyBase { + protected scene: Phaser.Scene + public sprite!: Phaser.GameObjects.Graphics + protected healthBar!: Phaser.GameObjects.Graphics + protected quoteText!: Phaser.GameObjects.Text + + public maxHp: number + public hp: number + public speed: number + public readonly kpiDamage: number + public readonly hcReward: number + + protected pathPoints: PathPoint[] + protected currentPathIndex: number = 0 + protected x: number = 0 + protected y: number = 0 + + public isDead: boolean = false + public isActive: boolean = true + + get pathProgress(): number { + return this.currentPathIndex + } + public dotEffects: DotEffect[] = [] + public slowEffect: number = 0 + public slowTimer: number = 0 + public shieldCount: number = 0 + + constructor( + scene: Phaser.Scene, + pathPoints: PathPoint[], + maxHp: number, + speed: number, + kpiDamage: number, + hcReward: number + ) { + this.scene = scene + this.pathPoints = pathPoints + this.maxHp = maxHp + this.hp = maxHp + this.speed = speed + this.kpiDamage = kpiDamage + this.hcReward = hcReward + + if (pathPoints.length > 0) { + this.x = pathPoints[0].x + this.y = pathPoints[0].y + } + + this.sprite = scene.add.graphics() + this.healthBar = scene.add.graphics() + this.quoteText = scene.add + .text(this.x, this.y - 30, this.getQuote(), { + fontFamily: 'VT323, monospace', + fontSize: '12px', + color: '#FFFFFF', + backgroundColor: 'rgba(0,0,0,0.6)', + padding: { x: 3, y: 2 }, + }) + .setOrigin(0.5, 1) + .setDepth(15) + .setAlpha(0) + + this.drawSprite() + this.drawHealthBar() + } + + update(delta: number): void { + if (this.isDead || !this.isActive) return + this.processDOT(delta) + if (this.slowTimer > 0) { + this.slowTimer -= delta + if (this.slowTimer <= 0) this.slowEffect = 0 + } + this.moveAlongPath(delta) + this.drawHealthBar() + this.sprite.setPosition(this.x, this.y) + } + + protected processDOT(delta: number): void { + for (let i = this.dotEffects.length - 1; i >= 0; i--) { + const dot = this.dotEffects[i] + dot.timer -= delta + const tickDamage = (dot.damage / 1000) * delta + this.hp -= tickDamage + if (this.hp <= 0) { + this.die() + return + } + if (dot.timer <= 0) { + this.dotEffects.splice(i, 1) + } + } + } + + protected moveAlongPath(delta: number): void { + if (this.currentPathIndex >= this.pathPoints.length - 1) { + this.reachEnd() + return + } + const target = this.pathPoints[this.currentPathIndex + 1] + const currentSpeed = this.speed * (1 - this.slowEffect) + const distance = (currentSpeed * delta) / 1000 + + const dx = target.x - this.x + const dy = target.y - this.y + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist <= distance) { + this.x = target.x + this.y = target.y + this.currentPathIndex++ + } else { + this.x += (dx / dist) * distance + this.y += (dy / dist) * distance + } + } + + protected drawHealthBar(): void { + this.healthBar.clear() + const bw = 30 + const bh = 4 + const bx = this.x - bw / 2 + const by = this.y - 20 + const ratio = Math.max(0, this.hp / this.maxHp) + const color = ratio > 0.5 ? 0x22c55e : ratio > 0.25 ? 0xf59e0b : 0xef4444 + + this.healthBar.fillStyle(0x374151, 1) + this.healthBar.fillRect(bx, by, bw, bh) + this.healthBar.fillStyle(color, 1) + this.healthBar.fillRect(bx, by, bw * ratio, bh) + this.healthBar.setDepth(14) + } + + takeDamage(damage: number): void { + if (this.isDead) return + if (this.shieldCount > 0) { + this.shieldCount-- + this.showShieldBlock() + return + } + this.hp -= damage + this.drawHealthBar() + if (this.hp <= 0) { + this.die() + } + } + + private showShieldBlock(): void { + const txt = this.scene.add + .text(this.x, this.y - 35, '护盾!', { + fontFamily: 'VT323, monospace', + fontSize: '14px', + color: '#93C5FD', + }) + .setOrigin(0.5, 1) + .setDepth(20) + this.scene.tweens.add({ + targets: txt, + y: this.y - 55, + alpha: 0, + duration: 800, + onComplete: () => txt.destroy(), + }) + } + + addDOT(damage: number, duration: number): void { + this.dotEffects.push({ damage, duration, timer: duration }) + } + + addSlow(percent: number, duration: number): void { + this.slowEffect = Math.max(this.slowEffect, percent) + this.slowTimer = Math.max(this.slowTimer, duration) + } + + protected die(): void { + if (this.isDead) return + this.isDead = true + const manager = GameManager.getInstance() + manager.addHC(this.hcReward) + this.onDeath() + this.destroy() + } + + protected reachEnd(): void { + if (this.isDead) return + const manager = GameManager.getInstance() + manager.reduceKPI(this.kpiDamage) + this.isDead = true + this.destroy() + } + + protected onDeath(): void {} + + /** 显示头顶语录(短暂) */ + showQuote(): void { + this.quoteText.setText(this.getQuote()) + this.quoteText.setAlpha(1) + this.scene.time.delayedCall(1500, () => { + if (this.quoteText) this.quoteText.setAlpha(0) + }) + } + + abstract drawSprite(): void + abstract getQuote(): string + + destroy(): void { + this.sprite?.destroy() + this.healthBar?.destroy() + this.quoteText?.destroy() + } +} diff --git a/game/enemies/FreshGraduate.ts b/game/enemies/FreshGraduate.ts new file mode 100644 index 0000000..bb93c61 --- /dev/null +++ b/game/enemies/FreshGraduate.ts @@ -0,0 +1,24 @@ +import type Phaser from 'phaser' +import { EnemyBase, type PathPoint } from './EnemyBase' + +const QUOTES = ['求转正!', '我愿意加班!', '卷!卷!卷!'] + +export class FreshGraduate extends EnemyBase { + constructor(scene: Phaser.Scene, pathPoints: PathPoint[]) { + super(scene, pathPoints, 30, 120, 2, 10) + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + this.sprite.fillStyle(0x86efac, 1) + this.sprite.fillCircle(0, 0, 8) + this.sprite.setDepth(10) + this.sprite.setPosition(this.x, this.y) + } + + getQuote(): string { + return QUOTES[Math.floor(Math.random() * QUOTES.length)] + } +} diff --git a/game/enemies/OldEmployee.ts b/game/enemies/OldEmployee.ts new file mode 100644 index 0000000..e9c2493 --- /dev/null +++ b/game/enemies/OldEmployee.ts @@ -0,0 +1,39 @@ +import type Phaser from 'phaser' +import { EnemyBase, type PathPoint } from './EnemyBase' + +const QUOTES = ['我为公司立过功!', '我有10年经验!', '年龄不是问题!'] + +export class OldEmployee extends EnemyBase { + constructor(scene: Phaser.Scene, pathPoints: PathPoint[]) { + super(scene, pathPoints, 150, 50, 8, 30) + this.shieldCount = 3 + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 大蓝方块 + this.sprite.fillStyle(0x93c5fd, 1) + this.sprite.fillRect(-10, -10, 20, 20) + // 护盾外框(金色) + this.sprite.lineStyle(2, 0xfbbf24, 0.8) + this.sprite.strokeRect(-12, -12, 24, 24) + this.sprite.setDepth(10) + this.sprite.setPosition(this.x, this.y) + } + + override drawHealthBar(): void { + super.drawHealthBar() + // 绘制护盾数量标记 + if (!this.healthBar) return + for (let i = 0; i < this.shieldCount; i++) { + this.healthBar.fillStyle(0xfbbf24, 1) + this.healthBar.fillRect(this.x - 15 + i * 11, this.y - 28, 8, 4) + } + } + + getQuote(): string { + return QUOTES[Math.floor(Math.random() * QUOTES.length)] + } +} diff --git a/game/enemies/TroubleMaker.ts b/game/enemies/TroubleMaker.ts new file mode 100644 index 0000000..b502b1f --- /dev/null +++ b/game/enemies/TroubleMaker.ts @@ -0,0 +1,54 @@ +import type Phaser from 'phaser' +import { EnemyBase, type PathPoint } from './EnemyBase' +import { GameManager } from '../GameManager' + +const QUOTES = ['录音笔已开启', '这是违法的!', '我要仲裁!'] + +export class TroubleMaker extends EnemyBase { + constructor(scene: Phaser.Scene, pathPoints: PathPoint[]) { + super(scene, pathPoints, 80, 80, 5, 20) + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 三角形(叹号形状) + this.sprite.fillStyle(0xfca5a5, 1) + this.sprite.fillTriangle(0, -14, -12, 10, 12, 10) + // 叹号 + this.sprite.fillStyle(0x7f1d1d, 1) + this.sprite.fillRect(-2, -6, 4, 8) + this.sprite.fillRect(-2, 6, 4, 4) + this.sprite.setDepth(10) + this.sprite.setPosition(this.x, this.y) + } + + protected override onDeath(): void { + // 劳动仲裁:死亡时扣玩家 20 HC + const manager = GameManager.getInstance() + manager.spendHC(20) + // 显示提示文字 + const txt = this.scene.add + .text(this.x, this.y - 20, '劳动仲裁! -20HC', { + fontFamily: 'VT323, monospace', + fontSize: '16px', + color: '#FCA5A5', + backgroundColor: '#7F1D1D', + padding: { x: 4, y: 2 }, + }) + .setOrigin(0.5, 1) + .setDepth(25) + this.scene.tweens.add({ + targets: txt, + y: this.y - 50, + alpha: 0, + duration: 1500, + onComplete: () => txt.destroy(), + }) + } + + getQuote(): string { + return QUOTES[Math.floor(Math.random() * QUOTES.length)] + } +} diff --git a/game/enemies/WaveManager.ts b/game/enemies/WaveManager.ts new file mode 100644 index 0000000..73fb814 --- /dev/null +++ b/game/enemies/WaveManager.ts @@ -0,0 +1,182 @@ +import type Phaser from 'phaser' +import { EnemyBase, buildFullPath, type PathPoint } from './EnemyBase' +import { FreshGraduate } from './FreshGraduate' +import { OldEmployee } from './OldEmployee' +import { TroubleMaker } from './TroubleMaker' +import { BossVP } from './BossVP' + +interface EnemyGroup { + type: 'FreshGraduate' | 'OldEmployee' | 'TroubleMaker' | 'BossVP' + count: number + interval: number +} + +interface WaveConfig { + enemies: EnemyGroup[] +} + +const WAVE_CONFIG: WaveConfig[] = [ + { enemies: [{ type: 'FreshGraduate', count: 10, interval: 800 }] }, + { + enemies: [ + { type: 'FreshGraduate', count: 8, interval: 800 }, + { type: 'OldEmployee', count: 3, interval: 2000 }, + ], + }, + { + enemies: [ + { type: 'OldEmployee', count: 5, interval: 2000 }, + { type: 'TroubleMaker', count: 3, interval: 1500 }, + ], + }, + { + enemies: [ + { type: 'FreshGraduate', count: 12, interval: 600 }, + { type: 'TroubleMaker', count: 2, interval: 1500 }, + ], + }, + { + enemies: [ + { type: 'OldEmployee', count: 6, interval: 1500 }, + { type: 'TroubleMaker', count: 3, interval: 1500 }, + ], + }, + { + enemies: [ + { type: 'BossVP', count: 1, interval: 0 }, + { type: 'OldEmployee', count: 4, interval: 2000 }, + ], + }, +] + +export class WaveManager { + private scene: Phaser.Scene + private activeEnemies: EnemyBase[] = [] + private pathPoints: PathPoint[] + private currentWave: number = 0 + private isSpawning: boolean = false + private onWave3Complete?: () => void + private onAllWavesComplete?: () => void + private onDestroyRandomTower?: () => void + private wave3Completed: boolean = false + + constructor( + scene: Phaser.Scene, + onWave3Complete?: () => void, + onAllWavesComplete?: () => void, + onDestroyRandomTower?: () => void + ) { + this.scene = scene + this.pathPoints = buildFullPath() + this.onWave3Complete = onWave3Complete + this.onAllWavesComplete = onAllWavesComplete + this.onDestroyRandomTower = onDestroyRandomTower + } + + get totalWaves(): number { + return WAVE_CONFIG.length + } + + startNextWave(): void { + if (this.isSpawning || this.currentWave >= WAVE_CONFIG.length) return + const config = WAVE_CONFIG[this.currentWave] + this.currentWave++ + this.isSpawning = true + this.spawnWave(config) + } + + private spawnWave(config: WaveConfig): void { + // 将所有怪物组展开为按时间排列的生成序列 + const spawnQueue: { type: EnemyGroup['type']; delay: number }[] = [] + for (const group of config.enemies) { + for (let i = 0; i < group.count; i++) { + spawnQueue.push({ type: group.type, delay: group.interval * i }) + } + } + // 按 delay 升序排列,同时生成 + spawnQueue.sort((a, b) => a.delay - b.delay) + + let completed = 0 + for (const item of spawnQueue) { + this.scene.time.delayedCall(item.delay, () => { + this.spawnEnemy(item.type) + completed++ + if (completed === spawnQueue.length) { + this.isSpawning = false + } + }) + } + + if (spawnQueue.length === 0) this.isSpawning = false + } + + private spawnEnemy(type: EnemyGroup['type']): EnemyBase { + let enemy: EnemyBase + const pts = [...this.pathPoints] + + switch (type) { + case 'OldEmployee': + enemy = new OldEmployee(this.scene, pts) + break + case 'TroubleMaker': + enemy = new TroubleMaker(this.scene, pts) + break + case 'BossVP': + enemy = new BossVP(this.scene, pts, this.onDestroyRandomTower) + break + default: + enemy = new FreshGraduate(this.scene, pts) + } + + // 随机显示语录 + this.scene.time.delayedCall(1000 + Math.random() * 2000, () => { + if (!enemy.isDead) enemy.showQuote() + }) + + this.activeEnemies.push(enemy) + return enemy + } + + update(delta: number): void { + for (let i = this.activeEnemies.length - 1; i >= 0; i--) { + const e = this.activeEnemies[i] + if (e.isDead) { + this.activeEnemies.splice(i, 1) + continue + } + e.update(delta) + } + + // 检查第3波完成后触发周报 + if ( + this.currentWave === 3 && + !this.isSpawning && + this.activeEnemies.length === 0 && + !this.wave3Completed + ) { + this.wave3Completed = true + this.onWave3Complete?.() + } + + // 检查全部波次完成 + if ( + this.currentWave >= WAVE_CONFIG.length && + !this.isSpawning && + this.activeEnemies.length === 0 + ) { + this.onAllWavesComplete?.() + } + } + + getAllActiveEnemies(): EnemyBase[] { + return this.activeEnemies + } + + hasMoreWaves(): boolean { + return this.currentWave < WAVE_CONFIG.length + } + + getCurrentWaveNumber(): number { + return this.currentWave + } +}