feat(towers): 实现防御塔基类与4种塔及塔管理器

This commit is contained in:
Cloud Bot
2026-03-21 08:04:15 +00:00
parent 6a21ece3ee
commit 45cf1e4642
6 changed files with 708 additions and 0 deletions

90
game/towers/HRBPTower.ts Normal file
View File

@@ -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<TowerBaseType> = 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<TowerBaseType> {
return this.nearbyTowersBuff
}
getAttackSpeedBonus(): number {
return BUFF_ATTACK_SPEED_BONUS
}
// HRBP 无直接攻击
attack(_target: EnemyBase): void {}
}

View File

@@ -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(),
})
}
}

View File

@@ -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(),
})
}
}

View File

@@ -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)
}
},
})
}
}

145
game/towers/TowerBase.ts Normal file
View File

@@ -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()
}
}

246
game/towers/TowerManager.ts Normal file
View File

@@ -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<TowerType, number> = {
intern: 50,
senior: 120,
ppt: 100,
hrbp: 80,
}
export class TowerManager {
private scene: Phaser.Scene
private towers: TowerBase[] = []
private occupiedCells: Set<string> = 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<string> {
return this.occupiedCells
}
}