282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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<string, Phaser.GameObjects.Text>()
|
|
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<string>(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<DrawableEntity['kind'], number>
|
|
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
|
|
}
|