import Phaser from 'phaser' import type { MovementVector } from '../../../../input' import { IWT2_BOSS_METADATA } from '../../content/bosses' import { IWT2_CLASS_METADATA } from '../../content/classes' import type { Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, Iwt2GroundHazardState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2, } from '../../sim' type SceneDeps = { getMovement: () => MovementVector getSelectedPartyId: () => string getState: () => Iwt2ArenaState step: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState } type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState export class BulldromeArenaScene extends Phaser.Scene { private deps: SceneDeps private arenaGraphics?: Phaser.GameObjects.Graphics private entityGraphics?: Phaser.GameObjects.Graphics private telegraphGraphics?: Phaser.GameObjects.Graphics private labels = new Map() private lastHudPublish = 0 constructor(deps: SceneDeps) { super('bulldrome-arena') this.deps = deps } create() { this.cameras.main.setBackgroundColor('#10141b') this.arenaGraphics = this.add.graphics() this.telegraphGraphics = this.add.graphics() this.entityGraphics = this.add.graphics() this.drawState(this.deps.getState()) } update(_: number, delta: number) { const state = this.deps.step(this.deps.getMovement(), Math.min(0.05, delta / 1000)) this.drawState(state) } private drawState(state: Iwt2ArenaState) { if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return this.drawArena(state) this.drawTelegraphs(state) this.drawEntities(state) this.drawProjectiles(state) this.lastHudPublish += 1 } private drawArena(state: Iwt2ArenaState) { const graphics = this.arenaGraphics! graphics.clear() graphics.lineStyle(3, 0x3c4a59, 1) graphics.fillStyle(0x171d24, 1) graphics.fillRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12) graphics.strokeRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12) graphics.lineStyle(1, 0x26313d, 0.55) for (let x = 80; x < state.bounds.width; x += 80) { graphics.lineBetween(x, 0, x, state.bounds.height) } for (let y = 80; y < state.bounds.height; y += 80) { graphics.lineBetween(0, y, state.bounds.width, y) } } private drawTelegraphs(state: Iwt2ArenaState) { const graphics = this.telegraphGraphics! graphics.clear() state.indicators.forEach((indicator) => drawIndicator(graphics, indicator)) } private drawEntities(state: Iwt2ArenaState) { const graphics = this.entityGraphics! graphics.clear() for (const hazard of state.hazards) drawHazard(graphics, hazard) const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss] const liveIds = new Set(entities.map((entity) => entity.id)) for (const entity of entities.sort(entitySort)) { const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1 const color = entityColor(entity) graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha) graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1) if (entity.kind === 'boss') { graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75) graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75) } else if (entity.kind === 'hostileAdd') { const wing = entity.radius * 1.55 graphics.fillTriangle( entity.position.x + entity.facing.x * wing, entity.position.y, entity.position.x - entity.facing.x * entity.radius, entity.position.y - entity.radius, entity.position.x - entity.facing.x * entity.radius, entity.position.y + entity.radius, ) graphics.strokeTriangle( entity.position.x + entity.facing.x * wing, entity.position.y, entity.position.x - entity.facing.x * entity.radius, entity.position.y - entity.radius, entity.position.x - entity.facing.x * entity.radius, entity.position.y + entity.radius, ) } else { if (entity.id === this.deps.getSelectedPartyId()) { graphics.lineStyle(4, 0xfff4a8, 0.95) graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8) } graphics.fillCircle(entity.position.x, entity.position.y, entity.radius) graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius) } this.drawLabel(entity) this.drawHealthBar(graphics, entity) } for (const [id, label] of this.labels) { if (!liveIds.has(id)) { label.destroy() this.labels.delete(id) } } } private drawProjectiles(state: Iwt2ArenaState) { const graphics = this.entityGraphics! for (const projectile of state.projectiles) { const color = Number.parseInt(projectile.color.slice(1), 16) graphics.fillStyle(color, 0.95) graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75) graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3) graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius) } } private drawLabel(entity: DrawableEntity) { let label = this.labels.get(entity.id) const icon = entityIcon(entity) if (!label) { label = this.add.text(entity.position.x, entity.position.y, icon, { align: 'center', color: '#fff7df', fontFamily: 'ui-monospace, Consolas, monospace', fontSize: entity.kind === 'boss' ? '20px' : entity.kind === 'hostileAdd' ? '13px' : '15px', fontStyle: '900', }).setOrigin(0.5) this.labels.set(entity.id, label) } const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) label.setText(stunned ? '!' : icon) label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0)) label.setAlpha(entity.health <= 0 ? 0.35 : 1) } private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) { const width = entity.kind === 'boss' ? 92 : 34 const height = entity.kind === 'boss' ? 8 : 7 const y = entity.position.y + entity.radius + 8 const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth)) graphics.fillStyle(0x050608, 0.9) graphics.fillRect(entity.position.x - width / 2, y, width, height) graphics.fillStyle(ratio > 0.5 ? 0x4ac77d : ratio > 0.25 ? 0xf1c663 : 0xf05b4f, 1) graphics.fillRect(entity.position.x - width / 2, y, width * ratio, height) if (entity.kind === 'party' && entity.shield > 0) { const shieldRatio = Math.max(0, Math.min(1, entity.shield / entity.maxHealth)) graphics.fillStyle(0x58a8ff, 0.92) graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height) } } } function entitySort(a: DrawableEntity, b: DrawableEntity): number { const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record return rank[a.kind] - rank[b.kind] } function entityColor(entity: DrawableEntity): string { if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color if (entity.kind === 'hostileAdd') return '#f0b84f' return IWT2_CLASS_METADATA[entity.classId].color } function entityIcon(entity: DrawableEntity): string { if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].icon if (entity.kind === 'hostileAdd') return 'v' return IWT2_CLASS_METADATA[entity.classId].icon } function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) { const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds))) const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34 graphics.fillStyle(0xff7a2f, alpha) graphics.lineStyle(2, 0xffc15a, 0.55) graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius) graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius) } function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2ArenaIndicator) { const color = Number.parseInt(indicator.color.slice(1), 16) const active = indicator.phase === 'active' graphics.lineStyle(active ? 8 : 5, color, indicator.lineAlpha ?? (active ? 0.76 : 0.5)) graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09)) if (indicator.kind === 'lane') { const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width) graphics.fillPoints(points, true) graphics.strokePoints(points, true) return } if (indicator.kind === 'circle') { graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.radius) graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.radius) return } if (indicator.kind === 'cone') { const points = coneDangerPolygon( indicator.origin, indicator.direction, indicator.range, indicator.angleRadians, ) graphics.fillPoints(points, true) graphics.strokePoints(points, true) return } graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius) graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius) graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6) graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius) } function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) { const dx = end.x - start.x const dy = end.y - start.y const length = Math.max(1, Math.hypot(dx, dy)) const normal = { x: (-dy / length) * halfWidth, y: (dx / length) * halfWidth, } return [ new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y), new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y), new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y), new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y), ] } function coneDangerPolygon( origin: Iwt2Vec2, direction: Iwt2Vec2, range: number, angleRadians: number, ) { const baseAngle = Math.atan2(direction.y, direction.x) const halfAngle = angleRadians / 2 const points = [new Phaser.Math.Vector2(origin.x, origin.y)] const segmentCount = 14 for (let index = 0; index <= segmentCount; index += 1) { const t = index / segmentCount const angle = baseAngle - halfAngle + angleRadians * t points.push(new Phaser.Math.Vector2( origin.x + Math.cos(angle) * range, origin.y + Math.sin(angle) * range, )) } return points }