diff --git a/game/towers/HRBPTower.ts b/game/towers/HRBPTower.ts new file mode 100644 index 0000000..14b435e --- /dev/null +++ b/game/towers/HRBPTower.ts @@ -0,0 +1,90 @@ +import type Phaser from 'phaser' +import { TowerBase } from './TowerBase' +import type { EnemyBase } from '../enemies/EnemyBase' +import type { TowerBase as TowerBaseType } from './TowerBase' + +const BUFF_ATTACK_SPEED_BONUS = 0.2 + +export class HRBPTower extends TowerBase { + private buffCooldown: number = 0 + private readonly BUFF_INTERVAL = 500 + private nearbyTowersBuff: Set = new Set() + + constructor(scene: Phaser.Scene, gridX: number, gridY: number) { + super(scene, gridX, gridY, 80, 1, 0, 0) + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 菱形(粉色) + this.sprite.fillStyle(0xec4899, 1) + this.sprite.fillTriangle(0, -16, 16, 0, 0, 16) + this.sprite.fillTriangle(0, -16, -16, 0, 0, 16) + this.sprite.setPosition(this.px, this.py) + this.sprite.setDepth(10) + } + + override update(delta: number, enemies: EnemyBase[]): void { + // HRBP 没有攻击逻辑,只做 BUFF + void enemies + + if (!this.isActive) { + this.stamina = Math.min( + this.maxStamina, + this.stamina + (this.staminaRegen * delta) / 1000 + ) + if (this.stamina > 20) this.isActive = true + this.updateStaminaBar() + return + } + + this.buffCooldown -= delta + if (this.buffCooldown <= 0) { + this.buffCooldown = this.BUFF_INTERVAL + this.applyBuffToNearby() + } + } + + setNearbyTowers(towers: TowerBaseType[]): void { + this.nearbyTowersBuff = new Set(towers) + } + + private applyBuffToNearby(): void { + if (this.nearbyTowersBuff.size === 0) return + if (this.stamina < 5) { + this.isActive = false + return + } + this.stamina -= 5 + this.updateStaminaBar() + // BUFF 效果通过 attackSpeedMultiplier 外部读取 + // 这里显示一个粉色光圈效果 + this.showBuffEffect() + } + + private showBuffEffect(): void { + const g = this.scene.add.graphics() + g.lineStyle(2, 0xec4899, 0.6) + g.strokeCircle(this.px, this.py, 90) + g.setDepth(8) + this.scene.tweens.add({ + targets: g, + alpha: 0, + duration: 400, + onComplete: () => g.destroy(), + }) + } + + getBuffedTowers(): Set { + return this.nearbyTowersBuff + } + + getAttackSpeedBonus(): number { + return BUFF_ATTACK_SPEED_BONUS + } + + // HRBP 无直接攻击 + attack(_target: EnemyBase): void {} +} diff --git a/game/towers/InternTower.ts b/game/towers/InternTower.ts new file mode 100644 index 0000000..2710ad4 --- /dev/null +++ b/game/towers/InternTower.ts @@ -0,0 +1,91 @@ +import type Phaser from 'phaser' +import { TowerBase } from './TowerBase' +import { GameManager } from '../GameManager' +import type { EnemyBase } from '../enemies/EnemyBase' + +export class InternTower extends TowerBase { + private selfDestroyTimer: number = 0 + private readonly SELF_DESTROY_INTERVAL = 1000 + private destroyed: boolean = false + public onSelfDestroy?: (tower: InternTower) => void + + constructor(scene: Phaser.Scene, gridX: number, gridY: number) { + super(scene, gridX, gridY, 50, 1, 15, 1.5) + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 绿色小人(圆头+十字身体) + this.sprite.fillStyle(0x22c55e, 1) + this.sprite.fillCircle(0, -12, 8) + // 身体 + this.sprite.fillRect(-3, -4, 6, 14) + // 手臂 + this.sprite.fillRect(-12, -2, 24, 4) + this.sprite.setPosition(this.px, this.py) + this.sprite.setDepth(10) + } + + override update(delta: number, enemies: EnemyBase[]): void { + if (this.destroyed) return + + super.update(delta, enemies) + + // 被动:每秒1%概率离场 + this.selfDestroyTimer += delta + if (this.selfDestroyTimer >= this.SELF_DESTROY_INTERVAL) { + this.selfDestroyTimer -= this.SELF_DESTROY_INTERVAL + if (Math.random() < 0.01) { + // 退还 25 HC + GameManager.getInstance().addHC(25) + this.showMessage('实习生跑路!+25HC') + this.destroyed = true + this.onSelfDestroy?.(this) + this.destroy() + return + } + } + } + + attack(target: EnemyBase): void { + // 整顿职场:5% 概率秒杀 HP < 500 的怪物 + if (Math.random() < 0.05 && target.hp < 500) { + target.takeDamage(9999) + this.showMessage('整顿职场!秒杀!') + } else { + target.takeDamage(this.attackDamage) + } + // 近战效果(闪光) + this.showMeleeEffect(target) + } + + private showMeleeEffect(target: EnemyBase): void { + const g = this.scene.add.graphics() + g.fillStyle(0x22c55e, 0.7) + g.fillCircle(target.sprite.x, target.sprite.y, 10) + g.setDepth(15) + this.scene.time.delayedCall(150, () => g.destroy()) + } + + private showMessage(msg: string): void { + const txt = this.scene.add + .text(this.px, this.py - 30, msg, { + fontFamily: 'VT323, monospace', + fontSize: '14px', + color: '#22C55E', + backgroundColor: '#14532D', + padding: { x: 4, y: 2 }, + }) + .setOrigin(0.5, 1) + .setDepth(20) + this.scene.tweens.add({ + targets: txt, + y: this.py - 55, + alpha: 0, + duration: 1200, + onComplete: () => txt.destroy(), + }) + } +} diff --git a/game/towers/PPTMasterTower.ts b/game/towers/PPTMasterTower.ts new file mode 100644 index 0000000..9411dfe --- /dev/null +++ b/game/towers/PPTMasterTower.ts @@ -0,0 +1,60 @@ +import type Phaser from 'phaser' +import { TowerBase } from './TowerBase' +import type { EnemyBase } from '../enemies/EnemyBase' + +export class PPTMasterTower extends TowerBase { + constructor(scene: Phaser.Scene, gridX: number, gridY: number) { + super(scene, gridX, gridY, 100, 3, 5, 1.5) + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 橙色圆形 + this.sprite.fillStyle(0xf59e0b, 1) + this.sprite.fillCircle(0, 0, 16) + // 圆心白点 + this.sprite.fillStyle(0xffffff, 0.9) + this.sprite.fillCircle(0, 0, 5) + this.sprite.setPosition(this.px, this.py) + this.sprite.setDepth(10) + } + + attack(target: EnemyBase): void { + // AOE 攻击:对射程内所有怪物造成伤害 + 减速 + this.showAoeEffect() + } + + /** 该方法由 TowerManager 调用,传入全体敌人 */ + attackAoe(enemies: EnemyBase[]): void { + const rangePx = this.attackRange * 80 // TILE_SIZE + this.showAoeEffect() + for (const e of enemies) { + if (e.isDead) continue + const dx = e.sprite.x - this.px + const dy = e.sprite.y - this.py + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist <= rangePx) { + e.takeDamage(this.attackDamage) + // 黑话领域:减速40%持续2秒 + e.addSlow(0.4, 2000) + } + } + } + + private showAoeEffect(): void { + const g = this.scene.add.graphics() + g.lineStyle(2, 0xf59e0b, 0.8) + g.strokeCircle(this.px, this.py, this.attackRange * 80) + g.setDepth(12) + this.scene.tweens.add({ + targets: g, + alpha: 0, + scaleX: 1.2, + scaleY: 1.2, + duration: 400, + onComplete: () => g.destroy(), + }) + } +} diff --git a/game/towers/SeniorDevTower.ts b/game/towers/SeniorDevTower.ts new file mode 100644 index 0000000..8484447 --- /dev/null +++ b/game/towers/SeniorDevTower.ts @@ -0,0 +1,76 @@ +import type Phaser from 'phaser' +import { TowerBase } from './TowerBase' +import type { EnemyBase } from '../enemies/EnemyBase' + +export class SeniorDevTower extends TowerBase { + constructor(scene: Phaser.Scene, gridX: number, gridY: number) { + super(scene, gridX, gridY, 120, 5, 30, 1.0) + this.drawSprite() + } + + drawSprite(): void { + if (!this.sprite) return + this.sprite.clear() + // 深蓝色方块 + this.sprite.fillStyle(0x3b82f6, 1) + this.sprite.fillRect(-12, -12, 24, 24) + this.sprite.lineStyle(1, 0x93c5fd, 1) + this.sprite.strokeRect(-12, -12, 24, 24) + this.sprite.setPosition(this.px, this.py) + this.sprite.setDepth(10) + + // 顶部 符号文字 + if (this.scene) { + const existing = this.scene.children.getByName(`dev_label_${this.gridX}_${this.gridY}`) + if (!existing) { + this.scene.add + .text(this.px, this.py, '', { + fontFamily: 'monospace', + fontSize: '10px', + color: '#DBEAFE', + }) + .setOrigin(0.5, 0.5) + .setDepth(11) + .setName(`dev_label_${this.gridX}_${this.gridY}`) + } + } + } + + attack(target: EnemyBase): void { + // 发射绿色代码块子弹 + this.fireBullet(target) + } + + private fireBullet(target: EnemyBase): void { + const bullet = this.scene.add.graphics() + bullet.fillStyle(0x22c55e, 1) + bullet.fillRect(-4, -4, 8, 8) + bullet.setPosition(this.px, this.py) + bullet.setDepth(13) + + const startX = this.px + const startY = this.py + const targetX = target.sprite.x + const targetY = target.sprite.y + + const dx = targetX - startX + const dy = targetY - startY + const dist = Math.sqrt(dx * dx + dy * dy) + const duration = (dist / 400) * 1000 + + this.scene.tweens.add({ + targets: bullet, + x: targetX, + y: targetY, + duration, + onComplete: () => { + bullet.destroy() + if (!target.isDead) { + target.takeDamage(this.attackDamage) + // 代码屎山:附加 DOT + target.addDOT(10, 3000) + } + }, + }) + } +} diff --git a/game/towers/TowerBase.ts b/game/towers/TowerBase.ts new file mode 100644 index 0000000..4ae6a98 --- /dev/null +++ b/game/towers/TowerBase.ts @@ -0,0 +1,145 @@ +import type Phaser from 'phaser' +import { GameManager } from '../GameManager' +import { TILE_SIZE, HUD_HEIGHT, COFFEE_COST, STAMINA_MAX, STAMINA_REGEN } from '../constants' +import type { EnemyBase } from '../enemies/EnemyBase' + +export abstract class TowerBase { + protected scene: Phaser.Scene + public gridX: number + public gridY: number + protected sprite!: Phaser.GameObjects.Graphics + protected staminaBar!: Phaser.GameObjects.Graphics + + public readonly cost: number + public readonly attackRange: number + public readonly attackDamage: number + public readonly attackSpeed: number + public readonly maxStamina: number = STAMINA_MAX + public stamina: number = STAMINA_MAX + + protected attackCooldown: number = 0 + protected staminaRegen: number = STAMINA_REGEN + protected isActive: boolean = true + + // Pixel center position + protected px: number + protected py: number + + constructor( + scene: Phaser.Scene, + gridX: number, + gridY: number, + cost: number, + attackRange: number, + attackDamage: number, + attackSpeed: number + ) { + this.scene = scene + this.gridX = gridX + this.gridY = gridY + this.cost = cost + this.attackRange = attackRange + this.attackDamage = attackDamage + this.attackSpeed = attackSpeed + + this.px = gridX * TILE_SIZE + TILE_SIZE / 2 + this.py = gridY * TILE_SIZE + TILE_SIZE / 2 + HUD_HEIGHT + + this.sprite = scene.add.graphics() + this.staminaBar = scene.add.graphics() + + this.drawSprite() + this.updateStaminaBar() + } + + update(delta: number, enemies: EnemyBase[]): void { + this.attackCooldown -= delta + + if (this.stamina <= 0) { + this.isActive = false + } + + if (!this.isActive) { + this.stamina = Math.min( + this.maxStamina, + this.stamina + (this.staminaRegen * delta) / 1000 + ) + if (this.stamina > 20) this.isActive = true + this.updateStaminaBar() + return + } + + const target = this.findTarget(enemies) + if (target && this.attackCooldown <= 0) { + this.attack(target) + this.stamina -= 5 + this.attackCooldown = 1000 / this.attackSpeed + this.updateStaminaBar() + } else if (!target) { + this.stamina = Math.min( + this.maxStamina, + this.stamina + (this.staminaRegen * delta) / 1000 + ) + this.updateStaminaBar() + } + } + + protected findTarget(enemies: EnemyBase[]): EnemyBase | null { + const rangePx = this.attackRange * TILE_SIZE + let best: EnemyBase | null = null + let bestProgress = -1 + + for (const e of enemies) { + if (e.isDead) continue + const dx = e.sprite.x - this.px + const dy = e.sprite.y - this.py + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist <= rangePx) { + // 优先选取路径进度最深的(模拟血量最多/威胁最大) + const progress = e.pathProgress + if (progress > bestProgress) { + bestProgress = progress + best = e + } + } + } + return best + } + + protected updateStaminaBar(): void { + this.staminaBar.clear() + const bw = 40 + const bh = 4 + const bx = this.px - bw / 2 + const by = this.py + TILE_SIZE / 2 - 8 + + this.staminaBar.fillStyle(0x374151, 1) + this.staminaBar.fillRect(bx, by, bw, bh) + this.staminaBar.fillStyle(0xf59e0b, 1) + this.staminaBar.fillRect(bx, by, bw * (this.stamina / this.maxStamina), bh) + this.staminaBar.setDepth(11) + } + + buyCoffee(): boolean { + const manager = GameManager.getInstance() + if (manager.spendHC(COFFEE_COST)) { + this.stamina = this.maxStamina + this.isActive = true + this.updateStaminaBar() + return true + } + return false + } + + getPixelCenter(): { x: number; y: number } { + return { x: this.px, y: this.py } + } + + abstract attack(target: EnemyBase): void + abstract drawSprite(): void + + destroy(): void { + this.sprite?.destroy() + this.staminaBar?.destroy() + } +} diff --git a/game/towers/TowerManager.ts b/game/towers/TowerManager.ts new file mode 100644 index 0000000..5832010 --- /dev/null +++ b/game/towers/TowerManager.ts @@ -0,0 +1,246 @@ +import type Phaser from 'phaser' +import { GameManager } from '../GameManager' +import { TILE_SIZE, HUD_HEIGHT, COFFEE_COST } from '../constants' +import { PATH_TILES } from '../mapRenderer' +import type { EnemyBase } from '../enemies/EnemyBase' +import { TowerBase } from './TowerBase' +import { InternTower } from './InternTower' +import { SeniorDevTower } from './SeniorDevTower' +import { PPTMasterTower } from './PPTMasterTower' +import { HRBPTower } from './HRBPTower' + +export type TowerType = 'intern' | 'senior' | 'ppt' | 'hrbp' + +export const TOWER_COSTS: Record = { + intern: 50, + senior: 120, + ppt: 100, + hrbp: 80, +} + +export class TowerManager { + private scene: Phaser.Scene + private towers: TowerBase[] = [] + private occupiedCells: Set = new Set() + private infoLabel: Phaser.GameObjects.Text | null = null + + constructor(scene: Phaser.Scene) { + this.scene = scene + } + + canPlace(gridX: number, gridY: number): boolean { + const key = `${gridX},${gridY}` + return !PATH_TILES.has(key) && !this.occupiedCells.has(key) + } + + placeTower(gridX: number, gridY: number, type: TowerType): boolean { + if (!this.canPlace(gridX, gridY)) return false + + const cost = TOWER_COSTS[type] + const manager = GameManager.getInstance() + if (!manager.spendHC(cost)) return false + + const tower = this.createTower(type, gridX, gridY) + this.towers.push(tower) + this.occupiedCells.add(`${gridX},${gridY}`) + + // 监听实习生自毁事件 + if (tower instanceof InternTower) { + tower.onSelfDestroy = (t) => this.removeTower(t) + } + + return true + } + + private createTower(type: TowerType, gridX: number, gridY: number): TowerBase { + switch (type) { + case 'senior': + return new SeniorDevTower(this.scene, gridX, gridY) + case 'ppt': + return new PPTMasterTower(this.scene, gridX, gridY) + case 'hrbp': + return new HRBPTower(this.scene, gridX, gridY) + default: + return new InternTower(this.scene, gridX, gridY) + } + } + + update(delta: number, enemies: EnemyBase[]): void { + // 更新 HRBP 的周围塔列表 + for (const tower of this.towers) { + if (tower instanceof HRBPTower) { + const nearby = this.getTowersNear(tower.gridX, tower.gridY, 1, tower) + tower.setNearbyTowers(nearby) + } + } + + for (const tower of this.towers) { + if (tower instanceof PPTMasterTower) { + // PPT 大师特殊更新:如果可以攻击就 AOE + this.updatePPTTower(tower, delta, enemies) + } else { + tower.update(delta, enemies) + } + } + } + + private updatePPTTower( + tower: PPTMasterTower, + delta: number, + enemies: EnemyBase[] + ): void { + // 直接调用 super 逻辑(手动处理冷却) + tower['attackCooldown'] -= delta + + if (tower['stamina'] <= 0) tower['isActive'] = false + + if (!tower['isActive']) { + tower['stamina'] = Math.min( + tower.maxStamina, + tower['stamina'] + (tower['staminaRegen'] * delta) / 1000 + ) + if (tower['stamina'] > 20) tower['isActive'] = true + tower['updateStaminaBar']() + return + } + + const hasTarget = enemies.some((e) => { + if (e.isDead) return false + const dx = e.sprite.x - tower['px'] + const dy = e.sprite.y - tower['py'] + return Math.sqrt(dx * dx + dy * dy) <= tower.attackRange * TILE_SIZE + }) + + if (hasTarget && tower['attackCooldown'] <= 0) { + tower.attackAoe(enemies) + tower['stamina'] -= 5 + tower['attackCooldown'] = 1000 / tower.attackSpeed + tower['updateStaminaBar']() + } else if (!hasTarget) { + tower['stamina'] = Math.min( + tower.maxStamina, + tower['stamina'] + (tower['staminaRegen'] * delta) / 1000 + ) + tower['updateStaminaBar']() + } + } + + private getTowersNear( + gx: number, + gy: number, + range: number, + exclude: TowerBase + ): TowerBase[] { + return this.towers.filter( + (t) => + t !== exclude && + Math.abs(t.gridX - gx) <= range && + Math.abs(t.gridY - gy) <= range + ) + } + + /** 点击格子时检查是否有塔,显示信息 */ + handleTileClick(gridX: number, gridY: number): boolean { + const tower = this.getTowerAt(gridX, gridY) + if (!tower) return false + this.showTowerInfo(tower) + return true + } + + private showTowerInfo(tower: TowerBase): void { + this.infoLabel?.destroy() + + const px = tower.gridX * TILE_SIZE + TILE_SIZE / 2 + const py = tower.gridY * TILE_SIZE + TILE_SIZE / 2 + HUD_HEIGHT + + const label = this.scene.add + .text( + px, + py - 40, + `精力: ${Math.floor(tower.stamina)}% [点击购买咖啡 ${COFFEE_COST}HC]`, + { + fontFamily: 'VT323, monospace', + fontSize: '14px', + color: '#F59E0B', + backgroundColor: '#0A1628', + padding: { x: 6, y: 3 }, + } + ) + .setOrigin(0.5, 1) + .setDepth(30) + .setInteractive() + + label.on('pointerdown', () => { + const ok = tower.buyCoffee() + if (ok) { + label.setText(`☕ 喝了咖啡!精力满了!`) + this.scene.time.delayedCall(1000, () => label.destroy()) + } else { + label.setText('HC 不足!') + this.scene.time.delayedCall(800, () => label.destroy()) + } + }) + + this.infoLabel = label + this.scene.time.delayedCall(3000, () => { + if (this.infoLabel === label) this.infoLabel = null + label.destroy() + }) + } + + getTowerAt(gridX: number, gridY: number): TowerBase | null { + return ( + this.towers.find((t) => t.gridX === gridX && t.gridY === gridY) ?? null + ) + } + + removeTower(tower: TowerBase): void { + const idx = this.towers.indexOf(tower) + if (idx !== -1) { + this.towers.splice(idx, 1) + this.occupiedCells.delete(`${tower.gridX},${tower.gridY}`) + } + } + + removeRandomTower(): void { + if (this.towers.length === 0) return + const idx = Math.floor(Math.random() * this.towers.length) + const tower = this.towers[idx] + this.showDestroyEffect(tower) + tower.destroy() + this.removeTower(tower) + } + + private showDestroyEffect(tower: TowerBase): void { + const { x, y } = tower.getPixelCenter() + const txt = this.scene.add + .text(x, y - 20, '☠ 组织架构调整!被裁了!', { + fontFamily: 'VT323, monospace', + fontSize: '16px', + color: '#EF4444', + backgroundColor: '#7F1D1D', + padding: { x: 6, y: 3 }, + }) + .setOrigin(0.5, 1) + .setDepth(30) + this.scene.tweens.add({ + targets: txt, + y: y - 60, + alpha: 0, + duration: 2000, + onComplete: () => txt.destroy(), + }) + } + + getAllTowers(): TowerBase[] { + return this.towers + } + + hasTowerAt(gridX: number, gridY: number): boolean { + return this.occupiedCells.has(`${gridX},${gridY}`) + } + + getOccupiedCells(): Set { + return this.occupiedCells + } +}