Android build v1.1.15

This commit is contained in:
Warren H
2026-07-04 12:43:59 -04:00
parent a849cd2c69
commit 99e97e5208
47 changed files with 8470 additions and 1610 deletions
+1
View File
@@ -34,6 +34,7 @@
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 93
versionName "1.1.14"
versionCode 94
versionName "1.1.15"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+16
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
@@ -2030,6 +2031,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2969,6 +2976,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/phaser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz",
"integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -28,6 +28,7 @@
"dependencies": {
"@capacitor/android": "^8.4.0",
"@capacitor/core": "^8.4.0",
"phaser": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
+101 -1
View File
@@ -1,4 +1,4 @@
import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { extname, resolve, sep } from 'node:path'
@@ -10,6 +10,7 @@ const port = Number(process.env.ADMIN_PORT ?? 4174)
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url))
const iwt2BalanceOverridesPath = fileURLToPath(new URL('../src/modes/iwt2/content/balanceOverrides.ts', import.meta.url))
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))
@@ -248,6 +249,89 @@ function saveDungeonImage(database, dungeonId, payload) {
return imageUrl
}
function readIwt2BalanceOverrides() {
if (!existsSync(iwt2BalanceOverridesPath)) return { bosses: {} }
const source = readFileSync(iwt2BalanceOverridesPath, 'utf8')
const exportIndex = source.indexOf('IWT2_BALANCE_OVERRIDES')
const objectStart = source.indexOf('{', exportIndex)
const objectEnd = source.lastIndexOf('}')
if (exportIndex < 0 || objectStart < 0 || objectEnd <= objectStart) return { bosses: {} }
try {
const parsed = JSON.parse(source.slice(objectStart, objectEnd + 1))
return sanitizeIwt2BalanceOverrides(parsed)
} catch {
return { bosses: {} }
}
}
function sanitizeIwt2BalanceOverrides(value) {
const overrides = { bosses: {} }
const bosses = value && typeof value === 'object' && value.bosses && typeof value.bosses === 'object'
? value.bosses
: {}
for (const bossId of ['bulldrome', 'yian-kut-ku']) {
const boss = bosses[bossId]
if (!boss || typeof boss !== 'object') continue
const nextBoss = {}
if (Number.isFinite(Number(boss.maxHealth))) nextBoss.maxHealth = Math.max(1, Math.round(Number(boss.maxHealth)))
if (bossId === 'yian-kut-ku' && Number.isFinite(Number(boss.birdHealth))) {
nextBoss.birdHealth = Math.max(1, Math.round(Number(boss.birdHealth)))
}
if (Object.keys(nextBoss).length > 0) overrides.bosses[bossId] = nextBoss
}
return overrides
}
function writeIwt2BalanceOverrides(overrides) {
const source = [
"import type { Iwt2BalanceOverrides } from './bosses'",
'',
'// Generated by local admin panel. Commit this file with intended IWT2 balance changes.',
`export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = ${JSON.stringify(sanitizeIwt2BalanceOverrides(overrides), null, 2)}`,
'',
].join('\n')
writeFileSync(iwt2BalanceOverridesPath, source, { mode: 0o644 })
}
function updateIwt2EntityHp(entityId, payload) {
const maxHealth = Number(payload.maxHealth)
if (!Number.isFinite(maxHealth) || maxHealth <= 0) throw new Error('HP must be greater than zero.')
const roundedHealth = Math.round(maxHealth)
const overrides = readIwt2BalanceOverrides()
const bosses = { ...(overrides.bosses ?? {}) }
if (entityId === 'bulldrome' || entityId === 'yian-kut-ku') {
bosses[entityId] = {
...(bosses[entityId] ?? {}),
maxHealth: roundedHealth,
}
} else if (entityId === 'yian-kut-ku:yian-bird' || entityId === 'yian-bird') {
bosses['yian-kut-ku'] = {
...(bosses['yian-kut-ku'] ?? {}),
birdHealth: roundedHealth,
}
} else {
throw new Error('IWT2 entity not found.')
}
const nextOverrides = sanitizeIwt2BalanceOverrides({ bosses })
writeIwt2BalanceOverrides(nextOverrides)
return nextOverrides
}
function listIwt2BossAssets() {
if (!existsSync(bossImageDirectory)) return []
return readdirSync(bossImageDirectory, { withFileTypes: true })
.filter((entry) => entry.isFile() && bossImageContentTypes[extname(entry.name).toLowerCase()])
.map((entry) => {
const imagePath = resolve(bossImageDirectory, entry.name)
return {
filename: entry.name,
size: statSync(imagePath).size,
url: `/api/boss-images/${entry.name}`,
}
})
.sort((a, b) => a.filename.localeCompare(b.filename))
}
function sendFile(response, filePath) {
const contentTypes = {
'.css': 'text/css; charset=utf-8',
@@ -308,6 +392,22 @@ const server = createServer(async (request, response) => {
return
}
if (request.url === '/api/admin/iwt2/data' && request.method === 'GET') {
sendJson(response, 200, {
balanceOverrides: readIwt2BalanceOverrides(),
bossAssets: listIwt2BossAssets(),
})
return
}
const iwt2HpMatch = request.url.match(/^\/api\/admin\/iwt2\/entities\/([^/]+)\/hp$/)
if (iwt2HpMatch && request.method === 'PUT') {
const payload = await readJson(request)
const balanceOverrides = updateIwt2EntityHp(decodeURIComponent(iwt2HpMatch[1]), payload)
sendJson(response, 200, { ok: true, balanceOverrides })
return
}
if (!existsSync(databasePath)) {
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
return
+1050 -1
View File
File diff suppressed because it is too large Load Diff
+68 -1600
View File
File diff suppressed because it is too large Load Diff
+360 -1
View File
@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react'
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
import {
IWT2_BOSS_METADATA,
type Iwt2BalanceOverrides,
type Iwt2BossId,
type Iwt2BossMetadata,
} from '../modes/iwt2/content/bosses'
type AdminItem = {
id: number
@@ -130,7 +136,38 @@ type AdminData = {
classes: AdminClass[]
}
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes'
type Iwt2AdminAsset = {
filename: string
size: number
url: string
}
type Iwt2AdminData = {
balanceOverrides: Iwt2BalanceOverrides
bossAssets: Iwt2AdminAsset[]
}
type Iwt2AdminAttack = {
name: string
frequency: string
damage: string
details: string
}
type Iwt2AdminEntityRow = {
id: string
bossId: Iwt2BossId
kind: 'boss' | 'mob'
name: string
slug: string
icon: string
color: string
maxHealth: number
attacks: Iwt2AdminAttack[]
assets: Iwt2AdminAsset[]
}
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes' | 'iwt2'
type SavingState = Record<string, boolean>
type SetData = Dispatch<SetStateAction<AdminData | null>>
type SetSaving = Dispatch<SetStateAction<SavingState>>
@@ -144,6 +181,7 @@ const tabs: { id: AdminTab; label: string }[] = [
{ id: 'crafting', label: 'Crafting' },
{ id: 'upgrades', label: 'Upgrades' },
{ id: 'classes', label: 'Classes' },
{ id: 'iwt2', label: 'IWT2' },
]
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -189,6 +227,7 @@ export function AdminScreen({ onBack }: { onBack: () => void }) {
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'classes' && <ClassesTab data={data} />}
{tab === 'iwt2' && <Iwt2AdminTab />}
</section>
)
}
@@ -1155,6 +1194,196 @@ function ClassesTab({ data }: { data: AdminData }) {
)
}
function Iwt2AdminTab() {
const [metadata, setMetadata] = useState<Record<Iwt2BossId, Iwt2BossMetadata>>(IWT2_BOSS_METADATA)
const [adminData, setAdminData] = useState<Iwt2AdminData | null>(null)
const [error, setError] = useState('')
const [selectedEntityId, setSelectedEntityId] = useState<string>('bulldrome')
const [draftHp, setDraftHp] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchJson<Iwt2AdminData>(`${API}/iwt2/data`)
.then((result) => {
setAdminData(result)
setMetadata(applyIwt2AdminOverrides(IWT2_BOSS_METADATA, result.balanceOverrides))
})
.catch((e: unknown) => {
setError(e instanceof Error ? e.message : 'Failed to load IWT2 admin data')
setAdminData({ balanceOverrides: { bosses: {} }, bossAssets: [] })
})
}, [])
const rows = createIwt2AdminRows(metadata, adminData?.bossAssets ?? [])
const selectedRow = rows.find((row) => row.id === selectedEntityId) ?? rows[0]
async function saveHp(row: Iwt2AdminEntityRow) {
const value = Number(draftHp[row.id] ?? row.maxHealth)
if (!Number.isFinite(value) || value <= 0) {
alert('HP must be greater than zero.')
return
}
const nextHealth = Math.round(value)
setSaving((prev) => ({ ...prev, [row.id]: true }))
try {
await fetchJson(`${API}/iwt2/entities/${encodeURIComponent(row.id)}/hp`, jsonRequest('PUT', { maxHealth: nextHealth }))
setMetadata((current) => {
const boss = current[row.bossId]
return {
...current,
[row.bossId]: row.kind === 'boss'
? { ...boss, maxHealth: nextHealth }
: { ...boss, birdHealth: nextHealth },
}
})
setDraftHp((prev) => {
const next = { ...prev }
delete next[row.id]
return next
})
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving((prev) => ({ ...prev, [row.id]: false }))
}
}
return (
<div className="admin-panel iwt2-admin-panel">
{error && <p className="error-message">{error}</p>}
<div className="iwt2-admin-summary">
<span>{rows.length} mobs / bosses</span>
<span>{adminData?.bossAssets.length ?? 0} uploaded boss assets</span>
<span>HP edits write to IWT2 balance overrides</span>
</div>
<div className="iwt2-admin-layout">
<aside className="iwt2-admin-entity-list" aria-label="IWT2 mobs and bosses">
{rows.map((row) => (
<button
className={selectedRow?.id === row.id ? 'active' : ''}
key={row.id}
onClick={() => setSelectedEntityId(row.id)}
type="button"
>
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
<span>
<strong>{row.name}</strong>
<small>{row.kind} · {row.maxHealth} hp · {row.attacks.length} attacks</small>
</span>
</button>
))}
</aside>
{selectedRow && (
<Iwt2AdminDetail
draftHp={draftHp}
row={selectedRow}
saveHp={saveHp}
saving={saving}
setDraftHp={setDraftHp}
/>
)}
</div>
</div>
)
}
function Iwt2AdminDetail({
draftHp,
row,
saveHp,
saving,
setDraftHp,
}: {
draftHp: Record<string, string>
row: Iwt2AdminEntityRow
saveHp: (row: Iwt2AdminEntityRow) => void
saving: Record<string, boolean>
setDraftHp: Dispatch<SetStateAction<Record<string, string>>>
}) {
const value = draftHp[row.id] ?? String(row.maxHealth)
const changed = Number(value) !== row.maxHealth
return (
<section className="iwt2-admin-detail">
<header className="iwt2-admin-detail-hero">
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
<div>
<p className="eyebrow">{row.kind}</p>
<h2>{row.name}</h2>
<small>{row.slug} · source boss: {row.bossId}</small>
</div>
</header>
<label className="iwt2-admin-hp">
HP
<input
min="1"
step="1"
type="number"
value={value}
onChange={(event) => setDraftHp((prev) => ({ ...prev, [row.id]: event.target.value }))}
/>
<button
className="primary-button"
disabled={!changed || saving[row.id]}
onClick={() => saveHp(row)}
type="button"
>
{saving[row.id] ? 'Saving...' : 'Save HP'}
</button>
</label>
<section className="iwt2-admin-section">
<div className="iwt2-admin-section-heading">
<h3>Assets</h3>
<span>{row.assets.length} uploaded</span>
</div>
<div className="iwt2-admin-assets">
<div className="iwt2-admin-swatch" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>
<span>{row.icon}</span>
<strong>Runtime Token</strong>
<small>Icon and color used by current arena renderer.</small>
</div>
{row.assets.length > 0 ? row.assets.map((asset) => {
const label = iwt2AssetLabel(asset.filename)
return (
<a href={asset.url} key={asset.filename} target="_blank" rel="noreferrer">
<img src={asset.url} alt={`${row.name} ${label}`} />
<strong>{label}</strong>
<small>{asset.filename}</small>
</a>
)
}) : (
<p className="admin-item-desc">No uploaded raster asset matched. Runtime uses shape, icon, and color.</p>
)}
</div>
</section>
<section className="iwt2-admin-section">
<div className="iwt2-admin-section-heading">
<h3>Attacks</h3>
<span>{row.attacks.length} mechanics</span>
</div>
<div className="iwt2-admin-attacks">
<div className="iwt2-admin-attack-head">
<span>Attack</span>
<span>Frequency</span>
<span>Damage</span>
</div>
{row.attacks.map((attack) => (
<div className="iwt2-admin-attack-row" key={`${row.id}-${attack.name}`}>
<strong>{attack.name}</strong>
<span>{attack.frequency}</span>
<span>{attack.damage}</span>
<small>{attack.details}</small>
</div>
))}
</div>
</section>
</section>
)
}
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
return {
method,
@@ -1163,6 +1392,136 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
}
}
function applyIwt2AdminOverrides(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
overrides: Iwt2BalanceOverrides,
): Record<Iwt2BossId, Iwt2BossMetadata> {
return {
bulldrome: {
...metadata.bulldrome,
...overrides.bosses?.bulldrome,
},
'yian-kut-ku': {
...metadata['yian-kut-ku'],
...overrides.bosses?.['yian-kut-ku'],
},
}
}
function createIwt2AdminRows(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
bossAssets: Iwt2AdminAsset[],
): Iwt2AdminEntityRow[] {
const bosses = Object.values(metadata).map((boss): Iwt2AdminEntityRow => ({
assets: assetsForIwt2Boss(boss.id, bossAssets),
attacks: bossAttacks(boss),
bossId: boss.id,
color: boss.color,
icon: boss.icon,
id: boss.id,
kind: 'boss',
maxHealth: boss.maxHealth,
name: boss.name,
slug: boss.id,
}))
const yian = metadata['yian-kut-ku']
if (!yian.birdHealth) return bosses
return [
...bosses,
{
assets: [],
attacks: yianBirdAttacks(yian),
bossId: yian.id,
color: '#f0b84f',
icon: 'v',
id: `${yian.id}:yian-bird`,
kind: 'mob',
maxHealth: yian.birdHealth,
name: 'Yian Kut Ku Hatchling',
slug: 'yian-bird',
},
]
}
function bossAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
const attacks: Iwt2AdminAttack[] = [{
damage: `${boss.meleeDamage}`,
details: `Range ${boss.meleeRange}px`,
frequency: `${formatSeconds(boss.meleeCooldown)} cooldown`,
name: 'Melee',
}]
if (boss.chargeCooldown > 0) {
attacks.push({
damage: `${boss.chargeDamage}`,
details: `${formatSeconds(boss.chargeWindup)} windup, ${formatSeconds(boss.chargeStunSeconds)} stun / knockdown`,
frequency: `${formatSeconds(boss.chargeCooldown)} cooldown`,
name: 'Charge',
})
}
if (boss.slamDamage > 0) {
attacks.push({
damage: `${boss.slamDamage}`,
details: `${boss.slamRadius}px radius, ${formatSeconds(boss.slamStunSeconds)} stun / knockdown`,
frequency: 'Every 3rd charge',
name: 'Ground Slam',
})
}
if (boss.fireballCooldown && boss.fireballDamage) {
attacks.push({
damage: `${boss.fireballDamage} hit + ${boss.firePuddleDamage ?? 0}/tick puddle`,
details: `${boss.firePuddleRadius ?? 0}px puddle for ${formatSeconds(boss.firePuddleSeconds ?? 0)}`,
frequency: `${formatSeconds(boss.fireballCooldown)} cooldown`,
name: 'Fireball',
})
}
if (boss.birdWaveThresholds?.length) {
attacks.push({
damage: `${boss.birdContactDamage ?? 0} contact`,
details: `${boss.birdWaveThresholds.map((threshold) => `${Math.round(threshold * 100)}%`).join(', ')} HP thresholds`,
frequency: 'Health threshold',
name: 'Bird Wave',
})
}
return attacks
}
function yianBirdAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
return [
{
damage: `${boss.birdContactDamage ?? 0}`,
details: `Contact radius ${boss.birdRadius ?? 0}px`,
frequency: '1.15s cooldown',
name: 'Peck',
},
{
damage: '10',
details: `${formatSeconds(boss.birdFlightWindup ?? 0)} windup, ${formatSeconds(boss.birdStunSeconds ?? 0)} stun`,
frequency: `${formatSeconds(boss.birdFlightCooldown ?? 0)} cooldown`,
name: 'Flight Lane',
},
]
}
function assetsForIwt2Boss(bossId: Iwt2BossId, bossAssets: Iwt2AdminAsset[]): Iwt2AdminAsset[] {
const terms = bossId.split('-').filter((part) => part.length > 2)
return bossAssets.filter((asset) => {
const filename = asset.filename.toLowerCase()
return filename.includes(bossId) || terms.some((term) => filename.includes(term))
})
}
function iwt2AssetLabel(filename: string) {
const normalized = filename.toLowerCase()
if (normalized.includes('approach')) return 'Approach Illustration'
if (normalized.includes('guardians')) return 'Party Encounter Art'
if (normalized.includes('boss')) return 'Boss Portrait'
return 'Primary Boss Art'
}
function formatSeconds(value: number) {
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0$/, '')}s`
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
+1 -1
View File
@@ -109,7 +109,7 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
].filter(Boolean).join(' ')
const action = targetBinding ? {
binding: targetBinding,
iconStyle: controllerIconStyle ?? 'xbox',
iconStyle: controllerIconStyle ?? 'playstation',
} : null
return (
+85 -3
View File
@@ -13,6 +13,10 @@ import { Capacitor } from '@capacitor/core'
export type InputDevice = 'pc' | 'controller'
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
export type MovementVector = {
x: number
y: number
}
export const INPUT_ACTIONS = [
'navigateUp',
@@ -131,7 +135,7 @@ const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30
@@ -235,13 +239,13 @@ function loadPreferences() {
combatTouchLocked?: boolean
}
return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
controllerIconStyle: saved.controllerIconStyle ?? 'playstation',
directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
}
} catch {
return {
controllerIconStyle: 'xbox' as ControllerIconStyle,
controllerIconStyle: 'playstation' as ControllerIconStyle,
directPartyTargeting: false,
combatTouchLocked: Capacitor.isNativePlatform(),
}
@@ -530,6 +534,10 @@ function isCombatActive() {
return Boolean(document.querySelector('[data-combat-active="true"]'))
}
function hasContinuousMovementActive() {
return Boolean(document.querySelector('[data-continuous-movement-active="true"]'))
}
function firstConnectedGamepad() {
return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
}
@@ -686,6 +694,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.querySelector('[data-combat-active="true"]'),
)
const uiOverlay = hasUiOverlay()
if (
combatActive
&& !uiOverlay
&& hasContinuousMovementActive()
&& (token.startsWith('Axis0') || token.startsWith('Axis1'))
) return
const uiPriority = [
'navigateUp',
'navigateDown',
@@ -1046,6 +1060,74 @@ export function InputProvider({ children }: { children: ReactNode }) {
)
}
function normalizedVector(x: number, y: number): MovementVector {
const magnitude = Math.hypot(x, y)
if (magnitude <= 1) return { x, y }
return { x: x / magnitude, y: y / magnitude }
}
function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector {
const magnitude = Math.hypot(x, y)
if (magnitude < deadzone) return { x: 0, y: 0 }
const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone))
return {
x: (x / magnitude) * scaled,
y: (y / magnitude) * scaled,
}
}
export function useMovementVectorRef(enabled = true) {
const movementRef = useRef<MovementVector>({ x: 0, y: 0 })
useEffect(() => {
if (!enabled) {
movementRef.current = { x: 0, y: 0 }
return undefined
}
let frame = 0
const pressedKeys = new Set<string>()
const keyboardVector = () => normalizedVector(
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
)
const updateMovement = () => {
const gamepad = firstConnectedGamepad()
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
const keyboard = keyboardVector()
movementRef.current = Math.hypot(stick.x, stick.y) > 0
? stick
: keyboard
frame = window.requestAnimationFrame(updateMovement)
}
const onKeyDown = (event: KeyboardEvent) => {
if (isTextInput(document.activeElement)) return
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.add(event.code)
document.documentElement.dataset.inputDevice = 'pc'
event.preventDefault()
}
const onKeyUp = (event: KeyboardEvent) => {
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
pressedKeys.delete(event.code)
event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
movementRef.current = { x: 0, y: 0 }
}
}, [enabled])
return movementRef
}
export function useInput() {
const context = useContext(InputContext)
if (!context) throw new Error('useInput must be used inside InputProvider')
+9 -1
View File
@@ -2,13 +2,21 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Capacitor } from '@capacitor/core'
import './index.css'
import './App.css'
import App from './App.tsx'
import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
import { Iwt2BottomDisplay } from './modes/iwt2/Iwt2BottomDisplay.tsx'
const displayMode = new URLSearchParams(window.location.search).get('display')
createRoot(document.getElementById('root')!).render(
<StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
{displayMode === 'iwt2-bottom' ? (
<InputProvider>
<Iwt2BottomDisplay />
</InputProvider>
) : displayMode === 'bottom' ? (
<InputProvider>
<DualScreenBottomDisplay />
</InputProvider>
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useState } from 'react'
import { useGameAction } from '../../input'
import { BossArenaScreen } from './screens/BossArenaScreen'
import {
Iwt2CloudSaveScreen,
Iwt2CustomizeCharacterScreen,
Iwt2DungeonsScreen,
Iwt2HunterProfileScreen,
Iwt2ModePlaceholderScreen,
Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens'
import {
loadIwt2Save,
writeIwt2Save,
type Iwt2Save,
} from './save/iwt2Repository'
import type { Iwt2BossId } from './content/bosses'
type Iwt2Screen =
| 'menu'
| 'arena'
| 'cloud-save'
| 'dungeons'
| 'raids'
| 'roguelike'
| 'pvp'
| 'hunter-profile'
| 'customize-character'
| 'settings'
const IWT2_MENU_COLUMNS = 4
const MENU_ITEMS: Array<{
screen: Iwt2Screen
title: string
description: string
glyph: string
}> = [
{
screen: 'cloud-save',
title: 'Cloud Save',
description: 'Account sync shell for IWT2 progression and local save status.',
glyph: 'C',
},
{
screen: 'dungeons',
title: 'Dungeons',
description: 'Queue into IWT2 boss arenas. Bulldrome and Yian Kut Ku are playable slices.',
glyph: 'D',
},
{
screen: 'raids',
title: 'Raids',
description: 'Large-party encounter shell for future multi-group boss fights.',
glyph: 'R',
},
{
screen: 'roguelike',
title: 'Roguelike',
description: 'Run-based IWT2 progression shell for room chains and reward drafts.',
glyph: 'G',
},
{
screen: 'pvp',
title: 'PvP',
description: 'Competitive healing shell with controller targeting preserved.',
glyph: 'P',
},
{
screen: 'hunter-profile',
title: 'Hunter Profile',
description: 'IWT2 level, inventory summary, and collection log.',
glyph: 'H',
},
{
screen: 'customize-character',
title: 'Customize Character',
description: 'IWT2-only character identity and cosmetic setup shell.',
glyph: 'K',
},
{
screen: 'settings',
title: 'Settings',
description: 'IWT2 targeting preference and controller icon style.',
glyph: 'S',
},
]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
useEffect(() => {
writeIwt2Save(save)
}, [save])
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
onBackToGameSelect()
return
}
if (action === 'confirm') {
setScreen(MENU_ITEMS[selectedIndex].screen)
return
}
if (action === 'navigateUp' || action === 'navigateLeft') {
const offset = action === 'navigateUp' ? IWT2_MENU_COLUMNS : 1
setSelectedIndex((current) => Math.max(0, current - offset))
} else if (action === 'navigateDown' || action === 'navigateRight') {
const offset = action === 'navigateDown' ? IWT2_MENU_COLUMNS : 1
setSelectedIndex((current) => Math.min(MENU_ITEMS.length - 1, current + offset))
}
})
if (screen === 'arena') {
return (
<BossArenaScreen
bossId={selectedBossId}
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
)
}
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2DungeonsScreen
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setSelectedBossId(bossId)
setScreen('arena')
}}
/>
</main>
)
}
if (screen === 'raids' || screen === 'roguelike' || screen === 'pvp') {
const modeName = screen === 'raids' ? 'Raids' : screen === 'roguelike' ? 'Roguelike' : 'PvP'
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModePlaceholderScreen mode={modeName} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'hunter-profile') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CustomizeCharacterScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'cloud-save') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen save={save} onBack={() => setScreen('menu')} />
</main>
)
}
if (screen === 'settings') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
</main>
)
}
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
<div className="iwt2-menu-heading">
<p className="eyebrow">I Want To Heal 2</p>
<h1>Mode Select</h1>
</div>
<div className="iwt2-menu-grid">
{MENU_ITEMS.map((item, index) => (
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={item.screen}
onClick={() => setScreen(item.screen)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.title}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</div>
</section>
)}
</main>
)
}
function Iwt2Header({
onBackToGameSelect,
save,
}: {
onBackToGameSelect: () => void
save: Iwt2Save
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
</button>
<div className="character-summary">
<strong>{save.character.name}</strong>
<small>IWT2 Level {save.character.level}</small>
<small>{save.character.experience} XP</small>
</div>
</header>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { ControllerBindingLabel } from '../../components/ControllerIcons'
import { DEFAULT_BINDINGS, useInput } from '../../input'
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls'
import { abilitiesForHealer } from './content/healerAbilities'
export function Iwt2BottomDisplay() {
const {
bindings,
directPartyTargeting,
lastDevice,
} = useInput()
const activeBindings = lastDevice === 'controller'
? bindings.controller
: DEFAULT_BINDINGS.controller
const abilities = abilitiesForHealer('field_medic')
const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId])
return (
<main className="dual-bottom-display iwt2-bottom-display">
<section className="dual-controls-resource iwt2-bottom-resource">
<div>
<p className="eyebrow">I Want To Heal 2</p>
<strong>Field Medic</strong>
</div>
<div className="dual-controls-mana">
<span>Mana 100 / 100</span>
<div className="bar mana-bar"><span style={{ width: '100%' }} /></div>
</div>
</section>
<section className={`dual-controls-targets ${directPartyTargeting ? 'direct' : ''}`} aria-label="Party targets">
{directPartyTargeting ? (
partyTargets.map((target, index) => {
const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1'
const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '')
return (
<button type="button" key={target.id}>
<ControllerBindingLabel
binding={activeBindings[action]}
iconStyle="playstation"
/>{' '}
{label}
</button>
)
})
) : (
<>
<button type="button">
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
</button>
<button type="button">
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
</button>
</>
)}
</section>
<section className="dual-controls-spells iwt2-bottom-spell-grid" aria-label="Spells and cooldowns">
{abilities.map((ability, index) => {
const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1'
return (
<button className="spell iwt2-bottom-spell" key={ability.id} type="button">
<kbd>
<ControllerBindingLabel
binding={activeBindings[action]}
compact
iconStyle="playstation"
/>
</kbd>
<span className={`spell-icon spell-${ability.kind}`}>{ability.icon}</span>
<strong>{ability.name}</strong>
<small>{ability.manaCost} Mana</small>
</button>
)
})}
</section>
</main>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
export function AbilityBar({
abilities,
cooldowns,
mana,
onCast,
}: {
abilities: Iwt2HealerAbility[]
cooldowns: Record<string, number>
mana: number
onCast: (ability: Iwt2HealerAbility) => void
}) {
return (
<div className="iwt2-ability-bar">
{abilities.map((ability) => {
const remaining = cooldowns[ability.id] ?? 0
const disabled = remaining > 0 || mana < ability.manaCost
return (
<button
aria-label={`${ability.slot}. ${ability.name}`}
className="iwt2-ability-button"
disabled={disabled}
key={ability.id}
onClick={() => onCast(ability)}
title={`${ability.name} - ${ability.manaCost} MP`}
type="button"
>
<span>{ability.icon}</span>
<strong>{ability.slot}</strong>
<i>{remaining > 0 ? `${remaining.toFixed(1)}s` : `${ability.manaCost} MP`}</i>
</button>
)
})}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
export function ArenaBar({
className = '',
current,
max,
shield = 0,
}: {
className?: string
current: number
max: number
shield?: number
}) {
const percent = max > 0 ? Math.max(0, Math.min(100, (current / max) * 100)) : 0
const shieldPercent = max > 0 ? Math.max(0, Math.min(100, (shield / max) * 100)) : 0
return (
<div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return (
<div className="iwt2-boss-hud">
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
</div>
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { ControllerBindingLabel } from '../../../components/ControllerIcons'
import type { ControllerIconStyle } from '../../../input'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type { Iwt2EntityId, Iwt2PartyEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
export function PartyFrames({
party,
selectedPartyId,
targetBindings,
controllerIconStyle,
onTarget,
}: {
party: Iwt2PartyEntityState[]
selectedPartyId: Iwt2EntityId
targetBindings?: Array<string | null>
controllerIconStyle?: ControllerIconStyle
onTarget: (id: Iwt2EntityId) => void
}) {
return (
<aside className="iwt2-party-list" data-game-nav-active="true">
{party.map((member) => {
const meta = IWT2_CLASS_METADATA[member.classId]
const selected = member.id === selectedPartyId
const targetBinding = targetBindings?.[party.indexOf(member)]
return (
<button
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
key={member.id}
onClick={() => onTarget(member.id)}
type="button"
>
{targetBinding && (
<span className="iwt2-party-target-key">
<ControllerBindingLabel
binding={targetBinding}
iconStyle={controllerIconStyle ?? 'playstation'}
/>
</span>
)}
<span style={{ background: meta.color }}>{meta.icon}</span>
<div>
<div className="iwt2-party-row-title">
<strong>{meta.name}</strong>
<small>{Math.ceil(member.health)}</small>
</div>
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)}
<div className="iwt2-party-effects">
{member.hotEffects.map((effect) => (
<span className="iwt2-effect-badge hot" key={effect.id}>
{effect.label} {Math.ceil(effect.remainingSeconds)}s
</span>
))}
{member.shield > 0 && (
<span className="iwt2-effect-badge shield">
Shield {Math.ceil(member.shield)}
</span>
)}
</div>
<small className="iwt2-party-damage">Damage {Math.round(member.damageDone)}</small>
</div>
</button>
)
})}
</aside>
)
}
@@ -0,0 +1,6 @@
import type { Iwt2BalanceOverrides } from './bosses'
// Generated by local admin panel. Commit this file with intended IWT2 balance changes.
export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = {
bosses: {},
}
+134
View File
@@ -0,0 +1,134 @@
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku'
export type Iwt2BalanceOverrides = {
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
}
export type Iwt2BossMetadata = {
id: Iwt2BossId
name: string
icon: string
color: string
accentColor: string
maxHealth: number
radius: number
moveSpeed: number
meleeRange: number
meleeDamage: number
meleeCooldown: number
chargeCooldown: number
chargeWindup: number
chargeSpeed: number
chargeDamage: number
chargeStunSeconds: number
chargeLength: number
chargeOvershoot: number
chargeWidth: number
slamWindup: number
slamRadius: number
slamDamage: number
slamStunSeconds: number
fireballCooldown?: number
fireballWindup?: number
fireballSpeed?: number
fireballDamage?: number
fireballRadius?: number
firePuddleRadius?: number
firePuddleDamage?: number
firePuddleSeconds?: number
birdWaveThresholds?: number[]
birdFlightCooldown?: number
birdFlightWindup?: number
birdFlightSpeed?: number
birdHealth?: number
birdRadius?: number
birdContactDamage?: number
birdStunSeconds?: number
}
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
id: 'bulldrome',
name: 'Bulldrome',
icon: 'B',
color: '#8f5a3c',
accentColor: '#e6b17f',
maxHealth: 950,
radius: 31,
moveSpeed: 128,
meleeRange: 58,
meleeDamage: 9,
meleeCooldown: 1.05,
chargeCooldown: 4.75,
chargeWindup: 0.55,
chargeSpeed: 510,
chargeDamage: 32,
chargeStunSeconds: 0.75,
chargeLength: 560,
chargeOvershoot: 120,
chargeWidth: 62,
slamWindup: 0.45,
slamRadius: 104,
slamDamage: 24,
slamStunSeconds: 0.75,
}
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'yian-kut-ku',
name: 'Yian Kut Ku',
icon: 'Y',
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
radius: 29,
moveSpeed: 118,
meleeRange: 56,
meleeDamage: 8,
meleeCooldown: 1,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
fireballCooldown: 3.6,
fireballWindup: 0.55,
fireballSpeed: 285,
fireballDamage: 12,
fireballRadius: 9,
firePuddleRadius: 42,
firePuddleDamage: 7,
firePuddleSeconds: 8,
birdWaveThresholds: [0.9, 0.4],
birdFlightCooldown: 10,
birdFlightWindup: 0.85,
birdFlightSpeed: 360,
birdHealth: 58,
birdRadius: 16,
birdContactDamage: 10,
birdStunSeconds: 0.75,
}
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
if (!override) return metadata
return {
...metadata,
...override,
}
}
export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA)
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
bulldrome: BULLDROME_BOSS_METADATA,
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
}
+121
View File
@@ -0,0 +1,121 @@
export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogue' | 'warrior'
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId
name: string
role: Iwt2PlayerClassRole
icon: string
color: string
accentColor: string
maxHealth: number
moveSpeed: number
radius: number
attackRange: number
attackDamage: number
attackCooldown: number
castTime: number
projectileSpeed: number
}
export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> = {
healer: {
id: 'healer',
name: 'Player Healer',
role: 'healer',
icon: '+',
color: '#2fbf71',
accentColor: '#b7f7cf',
maxHealth: 125,
moveSpeed: 210,
radius: 15,
attackRange: 0,
attackDamage: 0,
attackCooldown: 1,
castTime: 0,
projectileSpeed: 0,
},
paladin: {
id: 'paladin',
name: 'Paladin Tank',
role: 'tank',
icon: '🛡',
color: '#f2c94c',
accentColor: '#fff0a8',
maxHealth: 190,
moveSpeed: 165,
radius: 17,
attackRange: 44,
attackDamage: 7,
attackCooldown: 0.85,
castTime: 0,
projectileSpeed: 0,
},
ranger: {
id: 'ranger',
name: 'Ranger',
role: 'damage',
icon: '🏹',
color: '#4aa3df',
accentColor: '#b9e3ff',
maxHealth: 115,
moveSpeed: 185,
radius: 14,
attackRange: 245,
attackDamage: 8,
attackCooldown: 0.62,
castTime: 0.32,
projectileSpeed: 410,
},
mage: {
id: 'mage',
name: 'Mage',
role: 'damage',
icon: '⚚',
color: '#c56cf0',
accentColor: '#efc4ff',
maxHealth: 100,
moveSpeed: 175,
radius: 14,
attackRange: 220,
attackDamage: 11,
attackCooldown: 0.78,
castTime: 0.42,
projectileSpeed: 330,
},
rogue: {
id: 'rogue',
name: 'Rogue',
role: 'damage',
icon: '††',
color: '#55efc4',
accentColor: '#c8fff2',
maxHealth: 110,
moveSpeed: 230,
radius: 14,
attackRange: 38,
attackDamage: 6,
attackCooldown: 0.38,
castTime: 0,
projectileSpeed: 0,
},
warrior: {
id: 'warrior',
name: 'Warrior',
role: 'damage',
icon: '⚔',
color: '#ff7675',
accentColor: '#ffd0d0',
maxHealth: 150,
moveSpeed: 180,
radius: 16,
attackRange: 42,
attackDamage: 9,
attackCooldown: 0.52,
castTime: 0,
projectileSpeed: 0,
},
}
export const IWT2_PARTY_ORDER: Iwt2PlayerClassId[] = ['healer', 'paladin', 'ranger', 'mage', 'rogue', 'warrior']
+19
View File
@@ -0,0 +1,19 @@
import type { InputAction } from '../../../input'
export const IWT2_ABILITY_ACTIONS: InputAction[] = [
'ability1',
'ability2',
'ability3',
'ability4',
'ability5',
'ability6',
]
export const IWT2_TARGET_ACTIONS: InputAction[] = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
]
+125
View File
@@ -0,0 +1,125 @@
import type { Spell } from '../../../game'
import type { Ability } from '../../../profile'
import { toCombatSpell } from '../../../combat/rules'
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
export type Iwt2HealerAbility = {
id: string
healerId: string
slot: number
name: string
icon: string
kind: Spell['kind']
power: number
effectType?: string
manaCost: number
cooldownSeconds: number
target: Iwt2AbilityTarget
}
const IWT1_DAWNWEAVER_ABILITIES: Ability[] = [
{
id: 1,
classId: 1,
slug: 'mend',
name: 'Mend',
spellType: 'direct_heal',
cost: 5,
cooldown: 0.5,
power: 30,
unlockLevel: 1,
glyph: '+',
description: 'A fast, efficient single-target heal.',
},
{
id: 2,
classId: 1,
slug: 'renew',
name: 'Renew',
spellType: 'heal_over_time',
cost: 7,
cooldown: 0.5,
power: 12,
unlockLevel: 1,
glyph: '~',
description: 'Heals now and continues healing over time.',
},
{
id: 3,
classId: 1,
slug: 'radiance',
name: 'Radiance',
spellType: 'party_heal',
cost: 12,
cooldown: 8,
power: 18,
unlockLevel: 1,
glyph: '*',
description: 'Restores health to up to 4 injured party members.',
},
{
id: 4,
classId: 1,
slug: 'sun-ward',
name: 'Sun Ward',
spellType: 'absorb',
cost: 8,
cooldown: 7,
power: 36,
unlockLevel: 1,
glyph: 'O',
description: 'Places a damage-absorbing shield on your target.',
},
{
id: 5,
classId: 1,
slug: 'purify',
name: 'Purify',
spellType: 'cleanse',
cost: 5,
cooldown: 5,
power: 10,
unlockLevel: 1,
glyph: 'x',
description: 'Removes a harmful effect and restores health.',
},
{
id: 6,
classId: 1,
slug: 'dawn-burst',
name: 'Dawn Burst',
spellType: 'party_heal',
cost: 16,
cooldown: 12,
power: 28,
unlockLevel: 5,
glyph: 'D',
description: 'A brilliant wave of healing for up to 4 injured allies.',
},
]
function toIwt2Ability(ability: Ability, index: number): Iwt2HealerAbility {
const spell = toCombatSpell(ability, String(index + 1))
return {
id: spell.id,
healerId: 'field_medic',
slot: index + 1,
name: spell.name,
icon: spell.glyph,
kind: spell.kind,
power: spell.power,
effectType: spell.effectType,
manaCost: spell.cost,
cooldownSeconds: spell.cooldown,
target: 'party-member',
}
}
export const IWT2_HEALER_ABILITIES: Record<string, Iwt2HealerAbility[]> = {
field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability),
}
export function abilitiesForHealer(healerId: string) {
return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic
}
+18
View File
@@ -0,0 +1,18 @@
export {
IWT2_CLASS_METADATA,
IWT2_PARTY_ORDER,
} from './classes'
export type {
Iwt2ClassMetadata,
Iwt2PlayerClassId,
Iwt2PlayerClassRole,
} from './classes'
export {
BULLDROME_BOSS_METADATA,
IWT2_BOSS_METADATA,
} from './bosses'
export type {
Iwt2BossId,
Iwt2BossMetadata,
} from './bosses'
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useRef, type MutableRefObject } from 'react'
import Phaser from 'phaser'
import type { MovementVector } from '../../../input'
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
import type { Iwt2ArenaState } from '../sim/arenaState'
type PhaserArenaProps = {
movementRef: MutableRefObject<MovementVector>
selectedPartyIdRef: MutableRefObject<string>
stateRef: MutableRefObject<Iwt2ArenaState>
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
}
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
const hostRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!hostRef.current) return undefined
const initialState = stateRef.current
const scene = new BulldromeArenaScene({
getMovement: () => movementRef.current,
getSelectedPartyId: () => selectedPartyIdRef.current,
getState: () => stateRef.current,
step: onStep,
})
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: hostRef.current,
width: initialState.bounds.width,
height: initialState.bounds.height,
backgroundColor: '#10141b',
pixelArt: false,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene,
})
return () => {
game.destroy(true)
}
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
return <div className="iwt2-phaser-host" ref={hostRef} />
}
@@ -0,0 +1,281 @@
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
}
+115
View File
@@ -0,0 +1,115 @@
export type Iwt2InventoryItem = {
id: string
name: string
quantity: number
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
}
export type Iwt2CollectionLog = {
bossKills: Record<string, number>
dropsFound: Record<string, number>
}
export type Iwt2Save = {
version: 1
updatedAt: number
character: {
name: string
level: number
experience: number
}
inventory: Iwt2InventoryItem[]
collectionLog: Iwt2CollectionLog
}
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
export function createDefaultIwt2Save(): Iwt2Save {
return {
version: 1,
updatedAt: Date.now(),
character: {
name: 'Healer',
level: 1,
experience: 0,
},
inventory: [],
collectionLog: {
bossKills: {},
dropsFound: {},
},
}
}
function normalizeSave(value: unknown): Iwt2Save {
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
const candidate = value as Partial<Iwt2Save>
if (candidate.version !== 1) return createDefaultIwt2Save()
return {
version: 1,
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
character: {
name: candidate.character?.name || 'Healer',
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)),
},
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog?.bossKills ?? {},
dropsFound: candidate.collectionLog?.dropsFound ?? {},
},
}
}
export function loadIwt2Save(): Iwt2Save {
try {
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
} catch {
return createDefaultIwt2Save()
}
}
export function writeIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
...save,
updatedAt: Date.now(),
}))
}
export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save {
const drop = bossDropFor(bossId)
return {
...save,
updatedAt: Date.now(),
character: {
...save.character,
experience: save.character.experience + 125,
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + 125) / 500)),
},
inventory: [
...save.inventory,
{
id: `${drop.id}-${Date.now()}`,
name: drop.name,
quantity: 1,
rarity: 'common',
},
],
collectionLog: {
...save.collectionLog,
bossKills: {
...save.collectionLog.bossKills,
[bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1,
},
dropsFound: {
...save.collectionLog.dropsFound,
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
},
},
}
}
function bossDropFor(bossId: string): { id: string, name: string } {
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
}
+354
View File
@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
createInitialIwt2ArenaState,
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKill,
type Iwt2Save,
} from '../save/iwt2Repository'
import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'menu'
type BossArenaScreenProps = {
bossId: Iwt2BossId
save: Iwt2Save
onBack: () => void
onSaveUpdated: (save: Iwt2Save) => void
}
export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const stateRef = useRef(arenaState)
const statusRef = useRef(status)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const killRecordedRef = useRef(false)
const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({})
const movementRef = useMovementVectorRef(status === 'playing')
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
statusRef.current = status
}, [status])
useEffect(() => {
selectedPartyIdRef.current = selectedPartyId
}, [selectedPartyId])
useEffect(() => {
saveRef.current = save
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId)
killRecordedRef.current = false
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next
setArenaState(next)
setAbilityCooldowns({})
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossId])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
setStatus(nextStatus)
}, [])
const abilities = useMemo(
() => abilitiesForHealer('field_medic'),
[],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
if (statusRef.current !== 'playing') return
if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return
const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current)
if (!result.cast) return
const nextCooldowns = {
...abilityCooldownsRef.current,
[ability.id]: ability.cooldownSeconds,
}
abilityCooldownsRef.current = nextCooldowns
stateRef.current = result.state
lastHudSignatureRef.current = arenaHudSignature(result.state)
setAbilityCooldowns(nextCooldowns)
setArenaState(result.state)
}, [])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
if (action.startsWith('navigate')) {
setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary')
return
}
if (action === 'confirm') {
if (selectedOverlayAction === 'menu') onBack()
else if (statusRef.current === 'paused') setStatus('playing')
else resetArena()
return
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
else onBack()
return
}
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
return
}
if (
device === 'controller'
&& statusRef.current === 'playing'
&& !directPartyTargeting
&& (
action === 'navigateUp'
|| action === 'navigateLeft'
|| action === 'previousTarget'
|| action === 'navigateDown'
|| action === 'navigateRight'
|| action === 'nextTarget'
)
) {
const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget'
setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1))
return
}
if (action.startsWith('targetParty')) {
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
return
}
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
if (statusRef.current !== 'playing') return stateRef.current
abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds)
const next = tickIwt2Arena(stateRef.current, {
moveX: movement.x,
moveY: movement.y,
}, dtSeconds)
stateRef.current = next
if (next.boss.health <= 0 && !killRecordedRef.current) {
killRecordedRef.current = true
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
saveRef.current = updatedSave
onSaveUpdated(updatedSave)
showOverlay('victory')
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
}
const hudSignature = arenaHudSignature(next)
if (
hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08
|| next.boss.health <= 0
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time
setArenaState(next)
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [bossId, onSaveUpdated, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
: undefined
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart'
const overlayTitle = status === 'victory'
? `${bossMetadata.name} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
const overlayEyebrow = status === 'victory'
? 'Hunt Complete'
: status === 'defeat'
? 'Recovery'
: 'Paused'
const overlayTone = status === 'victory'
? 'is-victory'
: status === 'defeat'
? 'is-defeat'
: 'is-paused'
return (
<main
className="game-shell iwt2-arena-shell"
data-combat-active={status === 'playing' ? 'true' : 'false'}
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
>
<section className="iwt2-arena-layout">
<div className="iwt2-arena-stage">
<BossHud boss={arenaState.boss} />
<PartyFrames
controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId}
party={arenaState.party}
selectedPartyId={selectedPartyId}
targetBindings={targetBindings}
/>
<PhaserArena
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
{status !== 'playing' && (
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
<div className="iwt2-result-panel">
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
<span>{bossMetadata.icon}</span>
</div>
<p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1>
{status !== 'paused' && (
<div className="iwt2-result-summary" aria-label="Arena result summary">
<span>
<strong>{formatArenaTime(arenaState.time)}</strong>
Clear
</span>
<span>
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
)}
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+125 XP</span>
<span>Common carve</span>
<span>Log +1</span>
</div>
)}
<div className="iwt2-overlay-actions">
<button
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={status === 'paused' ? () => setStatus('playing') : resetArena}
type="button"
>
{overlayPrimaryLabel}
</button>
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={onBack}
type="button"
>
Menu
</button>
</div>
{lastDevice === 'controller' && (
<small className="iwt2-result-hint">D-pad selects, A confirms, B exits</small>
)}
</div>
</div>
)}
<AbilityBar
abilities={abilities}
cooldowns={abilityCooldowns}
mana={playerMana}
onCast={castAbility}
/>
</div>
</section>
</main>
)
}
function formatArenaTime(seconds: number): string {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
const remainder = safeSeconds % 60
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
state.boss.id,
Math.ceil(state.boss.health),
state.boss.attackPhase,
state.hostileAdds.length,
state.hazards.length,
...state.party.map((member) => [
member.id,
Math.ceil(member.health),
Math.ceil(member.shield),
Math.ceil(member.mana),
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
].join(':')),
].join('|')
}
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
const living = state.party.filter((member) => member.health > 0)
const targets = living.length > 0 ? living : state.party
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
const nextIndex = (currentIndex + direction + targets.length) % targets.length
return targets[nextIndex]?.id ?? currentId
}
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
let changed = false
const next: Record<string, number> = {}
for (const [id, remaining] of Object.entries(cooldowns)) {
const value = Math.max(0, remaining - dt)
if (value !== remaining) changed = true
if (value > 0) next[id] = value
}
return changed ? next : cooldowns
}
+337
View File
@@ -0,0 +1,337 @@
import { useMemo, useState, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons'
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
import type { Iwt2Save } from '../save/iwt2Repository'
import type { Iwt2BossId } from '../content/bosses'
type Iwt2NavAction = {
key: string
label: string
detail?: string
value?: string
onConfirm: () => void
}
type Iwt2ScreenShellProps = {
title: string
eyebrow?: string
onBack: () => void
children: ReactNode
}
type Iwt2ActionListProps = {
actions: Iwt2NavAction[]
}
function useIwt2ActionList(actions: Iwt2NavAction[]) {
const [selectedIndex, setSelectedIndex] = useState(0)
const activeIndex = Math.min(selectedIndex, Math.max(0, actions.length - 1))
useGameAction((action, device) => {
if (device !== 'controller' || actions.length === 0) return
if (action === 'navigateUp' || action === 'navigateLeft') {
setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateDown' || action === 'navigateRight') {
setSelectedIndex((current) => Math.min(actions.length - 1, current + 1))
} else if (action === 'confirm') {
actions[activeIndex]?.onConfirm()
}
})
return { selectedIndex: activeIndex, setSelectedIndex }
}
const MODE_DESCRIPTIONS = {
Raids: 'Large-party boss fights will use the same 2D arena contract with larger rosters and raid-scale mechanics.',
Roguelike: 'Runs will chain rooms, rewards, healer builds, and IWT2-only inventory progression.',
PvP: 'Player-vs-player healing scenarios will reuse controller targeting and class identity without IWT1 state bleed.',
} satisfies Record<'Raids' | 'Roguelike' | 'PvP', string>
const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo']
const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
}
export function Iwt2ScreenShell({
children,
eyebrow = 'I Want To Heal 2',
onBack,
title,
}: Iwt2ScreenShellProps) {
useGameAction((action, device) => {
if (device === 'controller' && action === 'back') onBack()
})
return (
<section className="content-screen iwt2-screen-shell" data-game-nav-active="true">
<div className="screen-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button className="back-button" data-controller-nav="skip" onClick={onBack} type="button">
Back
</button>
</div>
{children}
</section>
)
}
export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions)
return (
<div className="iwt2-action-list">
{actions.map((action, index) => (
<button
className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
key={action.key}
onClick={action.onConfirm}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>
<strong>{action.label}</strong>
{action.detail && <small>{action.detail}</small>}
</span>
{action.value && <em>{action.value}</em>}
</button>
))}
</div>
)
}
export function Iwt2DungeonsScreen({
onBack,
onOpenBoss,
}: {
onBack: () => void
onOpenBoss: (bossId: Iwt2BossId) => void
}) {
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'bulldrome',
label: 'Bulldrome Arena',
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
value: 'Ready',
onConfirm: () => onOpenBoss('bulldrome'),
},
{
key: 'yian-kut-ku',
label: 'Yian Kut Ku Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
onConfirm: () => onOpenBoss('yian-kut-ku'),
},
{
key: 'queue',
label: 'Dungeon Queue',
detail: 'Future matchmaking shell for multi-boss dungeon routes.',
value: 'Planned',
onConfirm: () => {},
},
], [onOpenBoss])
return (
<Iwt2ScreenShell title="Dungeons" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2ModePlaceholderScreen({
mode,
onBack,
}: {
mode: 'Raids' | 'Roguelike' | 'PvP'
onBack: () => void
}) {
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'status',
label: `${mode} Prototype`,
detail: MODE_DESCRIPTIONS[mode],
value: 'Shell',
onConfirm: () => {},
},
], [mode])
return (
<Iwt2ScreenShell title={mode} onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2HunterProfileScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
const bulldromeKills = save.collectionLog.bossKills.bulldrome ?? 0
const yianKills = save.collectionLog.bossKills['yian-kut-ku'] ?? 0
const bulldromeDrops = save.collectionLog.dropsFound['raw-bulldrome-coin'] ?? 0
const yianDrops = save.collectionLog.dropsFound['yian-kut-ku-scale'] ?? 0
return (
<Iwt2ScreenShell title="Hunter Profile" onBack={onBack}>
<div className="iwt2-profile-grid">
<div className="iwt2-list-panel">
<div className="iwt2-list-row">
<strong>Name</strong>
<span>{save.character.name}</span>
</div>
<div className="iwt2-list-row">
<strong>Level</strong>
<span>{save.character.level}</span>
</div>
<div className="iwt2-list-row">
<strong>Experience</strong>
<span>{save.character.experience} XP</span>
</div>
</div>
<div className="iwt2-list-panel">
<div className="iwt2-list-row">
<strong>Bulldrome Kills</strong>
<span>{bulldromeKills}</span>
</div>
<div className="iwt2-list-row">
<strong>Raw Bulldrome Coins</strong>
<span>{bulldromeDrops}</span>
</div>
<div className="iwt2-list-row">
<strong>Yian Kut Ku Kills</strong>
<span>{yianKills}</span>
</div>
<div className="iwt2-list-row">
<strong>Yian Kut Ku Scales</strong>
<span>{yianDrops}</span>
</div>
<div className="iwt2-list-row">
<strong>IWT2 Inventory</strong>
<span>{save.inventory.length} slots</span>
</div>
</div>
</div>
</Iwt2ScreenShell>
)
}
export function Iwt2CustomizeCharacterScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'class',
label: 'Healer Style',
detail: 'Current vertical slice uses Field Medic abilities based on IWT1 dungeon spells.',
value: 'Field Medic',
onConfirm: () => {},
},
{
key: 'appearance',
label: 'Armor Palette',
detail: 'Character cosmetics will stay scoped to IWT2 save data.',
value: 'Default',
onConfirm: () => {},
},
{
key: 'name',
label: 'Character Name',
detail: 'Name editing shell for the IWT2 hunter profile.',
value: save.character.name,
onConfirm: () => {},
},
], [save.character.name])
return (
<Iwt2ScreenShell title="Customize Character" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2CloudSaveScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
const savedAt = new Date(save.updatedAt).toLocaleString()
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'status',
label: 'Cloud Save Slot',
detail: 'Local IWT2 save is isolated and ready for cloud sync integration.',
value: 'Not linked',
onConfirm: () => {},
},
{
key: 'last-save',
label: 'Last Local Save',
detail: savedAt,
value: 'Local',
onConfirm: () => {},
},
], [savedAt])
return (
<Iwt2ScreenShell title="Cloud Save" onBack={onBack}>
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) {
const {
controllerIconStyle,
directPartyTargeting,
setControllerIconStyle,
setDirectPartyTargeting,
} = useInput()
const actions = useMemo<Iwt2NavAction[]>(() => [
{
key: 'direct-targeting',
label: 'Target Party Members With Bindings',
detail: 'When enabled, party frames show direct target button icons instead of D-pad cycling.',
value: directPartyTargeting ? 'On' : 'Off',
onConfirm: () => setDirectPartyTargeting(!directPartyTargeting),
},
...CONTROLLER_ICON_OPTIONS.map((style) => ({
key: `icons-${style}`,
label: `${CONTROLLER_ICON_LABELS[style]} Button Icons`,
detail: 'Changes controller glyphs shown on IWT2 frames and spell controls.',
value: controllerIconStyle === style ? 'Selected' : '',
onConfirm: () => setControllerIconStyle(style),
})),
], [
controllerIconStyle,
directPartyTargeting,
setControllerIconStyle,
setDirectPartyTargeting,
])
return (
<Iwt2ScreenShell title="Settings" onBack={onBack}>
<div className="iwt2-settings-panel">
<Iwt2ActionList actions={actions} />
<div className="iwt2-controller-preview" aria-label="Current controller icon style">
<span>{CONTROLLER_ICON_LABELS[controllerIconStyle]}</span>
<ControllerStylePreview iconStyle={controllerIconStyle} />
</div>
</div>
</Iwt2ScreenShell>
)
}
+646
View File
@@ -0,0 +1,646 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type {
Iwt2ArenaEvent,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
} from './types'
import type { Iwt2PlayerClassId } from '../content/classes'
import { tickBoss } from './bossAi'
import { separateCircles } from './collision'
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
const DEFAULT_ARENA_WIDTH = 960
const DEFAULT_ARENA_HEIGHT = 540
const MAX_DT = 1 / 15
const MAX_EVENTS = 80
const HEALER_MANA_REGEN_PER_SECOND = 3
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
type InitialPartyMember = {
id: Iwt2PartyEntityId
classId: Iwt2PlayerClassId
aiRole: Iwt2PartyAiRole
x: number
y: number
preferredOffset: { x: number; y: number }
decisionOffset: number
}
const INITIAL_PARTY: InitialPartyMember[] = [
{ id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 },
{ id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 },
{ id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 },
{ id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 },
{ id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 },
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
]
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
boss: {
id: bossId,
kind: 'boss',
bossId,
position: { x: 640, y: 250 },
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: bossMetadata.maxHealth,
maxHealth: bossMetadata.maxHealth,
meleeCooldownRemaining: 0.6,
chargeCooldownRemaining: bossId === 'bulldrome' ? 2 : 0,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { x: 640, y: 250 },
chargeEnd: { x: 640, y: 250 },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
fireballCooldownRemaining: bossId === 'yian-kut-ku' ? 1.2 : 0,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
},
nextEventId: 1,
nextProjectileId: 1,
nextAddId: 1,
nextHazardId: 1,
events: [],
}
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
const step = Math.max(0, Math.min(dt, MAX_DT))
if (step <= 0) return { ...state, events: [...state.events] }
const inputMove = {
x: clampInputAxis(input.moveX),
y: clampInputAxis(input.moveY),
}
const baseState = {
...state,
time: state.time + step,
party: state.party.map((member) => tickPartyMember(member, state, inputMove, step)),
events: [],
}
const separatedState = {
...baseState,
party: separatePartyFromBoss(baseState.party, baseState),
}
const bossResult = tickBoss(separatedState, step)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
bossResult.boss,
bossResult.hostileAdds ?? separatedState.hostileAdds,
bossResult.hazards ?? separatedState.hazards,
bossResult.nextHazardId ?? state.nextHazardId,
bossResult.nextProjectileId ?? state.nextProjectileId,
separatedState.bounds,
separatedState.time,
step,
)
const hazardResult = tickGroundHazards({
dt: step,
hazards: projectileResult.hazards,
party: projectileResult.party,
time: separatedState.time,
})
const damageResult = applyPartyAttacks(
hazardResult.party,
projectileResult.boss,
projectileResult.hostileAdds,
separatedState.time,
projectileResult.nextProjectileId,
)
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
const finalParty = regeneratePartyMana(hotResult.party, step)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
...separatedState,
boss: damageResult.boss,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
party: finalParty,
hostileAdds: damageResult.hostileAdds,
hazards: hazardResult.hazards,
projectiles: [...projectileResult.projectiles, ...damageResult.projectiles],
nextProjectileId: damageResult.nextProjectileId,
nextAddId: bossResult.nextAddId ?? state.nextAddId,
nextHazardId: projectileResult.nextHazardId,
nextEventId: state.nextEventId + nextEvents.length,
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
}
}
function tickPartyHotEffects(
party: Iwt2PartyEntityState[],
dt: number,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const events: Iwt2ArenaEvent[] = []
const nextParty = party.map((member) => {
if (member.hotEffects.length === 0) return member
let health = member.health
const hotEffects = member.hotEffects.flatMap((effect) => {
const remainingSeconds = effect.remainingSeconds - dt
if (remainingSeconds <= 0) return []
let nextTickInSeconds = effect.nextTickInSeconds - dt
if (member.health > 0 && nextTickInSeconds <= 0) {
const before = health
health = Math.min(member.maxHealth, health + effect.power)
const healed = health - before
if (healed > 0) {
events.push({
id: 0,
time,
type: 'partyHealed',
sourceId: 'player-healer',
targetId: member.id,
value: healed,
})
}
nextTickInSeconds += effect.tickIntervalSeconds
}
return [{
...effect,
nextTickInSeconds,
remainingSeconds,
}]
})
return {
...member,
health,
hotEffects,
}
})
return { party: nextParty, events }
}
function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member
return {
...member,
mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt),
}
})
}
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
return {
id: initial.id,
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
health: metadata.maxHealth,
maxHealth: metadata.maxHealth,
shield: 0,
mana: initial.classId === 'healer' ? 100 : 0,
maxMana: initial.classId === 'healer' ? 100 : 0,
damageDone: 0,
attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35,
castSecondsRemaining: 0,
attackReady: false,
status: createEmptyStatus(),
hotEffects: [],
preferredOffset: { ...initial.preferredOffset },
decisionSecondsRemaining: initial.decisionOffset,
}
}
function createEmptyStatus(): Iwt2StatusState {
return {
stunnedSeconds: 0,
knockedDownSeconds: 0,
invulnerableSeconds: 0,
}
}
function clampInputAxis(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(-1, value))
}
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
const position = separateCircles(
{ position: member.position, radius: member.radius },
{ position: state.boss.position, radius: state.boss.radius },
state.bounds,
)
return { ...member, position }
})
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[],
nextHazardId: number,
nextProjectileId: number,
bounds: Iwt2ArenaState['bounds'],
time: number,
dt: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextProjectileId: number
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextBoss = boss
let nextParty = party
let nextHostileAdds = hostileAdds
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
const nextProjectiles: Iwt2ProjectileEntityState[] = []
for (const projectile of projectiles) {
if (projectile.owner === 'boss') {
const result = advanceBossProjectile({
bounds,
dt,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
party: nextParty,
projectile,
time,
})
nextParty = result.party
nextHazards = result.hazards
nextHazardIdValue = result.nextHazardId
events.push(...result.events)
if (result.projectile) nextProjectiles.push(result.projectile)
continue
}
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue
const nextPosition = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
}
const remainingSeconds = projectile.remainingSeconds - dt
const hitAdd = nextHostileAdds.find((add) => (
add.health > 0
&& distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius
))
if (remainingSeconds <= 0) continue
if (hitAdd) {
const damage = Math.min(projectile.damage, hitAdd.health)
nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id
? { ...add, health: Math.max(0, add.health - damage) }
: add)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: hitAdd.id,
value: damage,
})
if (hitAdd.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: hitAdd.id,
})
}
continue
}
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
const damage = Math.min(projectile.damage, nextBoss.health)
nextBoss = {
...nextBoss,
health: Math.max(0, nextBoss.health - damage),
}
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
value: damage,
})
if (nextBoss.health <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: nextBoss.id,
})
}
continue
}
nextProjectiles.push({
...projectile,
position: nextPosition,
remainingSeconds,
})
}
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
nextProjectileId,
projectiles: nextProjectiles,
events,
}
}
function advanceBossProjectile({
bounds,
dt,
hazards,
nextHazardId,
party,
projectile,
time,
}: {
bounds: Iwt2ArenaState['bounds']
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
projectile: Iwt2ProjectileEntityState
time: number
}): {
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
projectile?: Iwt2ProjectileEntityState
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const remainingSeconds = projectile.remainingSeconds - dt
if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events }
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
let bounced = false
const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt)
const canBounce = bounceCooldownSeconds <= 0
let velocity = { ...projectile.velocity }
let position = {
x: projectile.position.x + projectile.velocity.x * dt,
y: projectile.position.y + projectile.velocity.y * dt,
}
if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) {
velocity = { ...velocity, x: -velocity.x }
position = clampVec2ToArena(position, projectile.radius, bounds)
bounced = true
}
if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) {
velocity = { ...velocity, y: -velocity.y }
position = clampVec2ToArena(position, projectile.radius, bounds)
bounced = true
}
const hitMember = canBounce
? nextParty.find((member) => (
member.health > 0
&& distanceVec2(position, member.position) <= projectile.radius + member.radius
))
: undefined
if (hitMember) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: hitMember.position,
radius: hitMember.radius + projectile.radius,
}, {
damage: projectile.damage,
sourceId: projectile.sourceId,
time,
})
nextParty = result.party
events.push(...result.events)
const away = normalizeVec2(subtractVec2(position, hitMember.position))
const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1))
const direction = away.x === 0 && away.y === 0 ? fallback : away
const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y))
velocity = scaleVec2(direction, speed)
position = clampVec2ToArena({
x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2),
y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2),
}, projectile.radius, bounds)
bounced = true
}
if (bounced) {
const puddle = addFirePuddle({
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: hitMember?.position ?? position,
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
sourceId: projectile.sourceId,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
const bouncesRemaining = bounced
? (projectile.bouncesRemaining ?? 0) - 1
: projectile.bouncesRemaining
if (bouncesRemaining !== undefined && bouncesRemaining < 0) {
return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events }
}
return {
events,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
party: nextParty,
projectile: {
...projectile,
bouncesRemaining,
bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds,
position,
remainingSeconds,
velocity,
},
}
}
function applyPartyAttacks(
party: Iwt2PartyEntityState[],
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
time: number,
nextProjectileId: number,
): {
boss: Iwt2ArenaState['boss']
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[]
nextProjectileId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let nextBoss = boss
let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId
const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) {
if (!member.attackReady) return member
const direction = normalizeVec2(subtractVec2(target.position, member.position))
projectiles.push({
id: `projectile-${projectileId}`,
sourceId: member.id,
owner: 'party',
classId: member.classId,
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
color: metadata.accentColor,
position: {
x: member.position.x + direction.x * (member.radius + 4),
y: member.position.y + direction.y * (member.radius + 4),
},
velocity: scaleVec2(direction, metadata.projectileSpeed),
radius: member.classId === 'mage' ? 7 : 4,
damage: metadata.attackDamage,
remainingSeconds: 1.2,
})
projectileId += 1
return {
...member,
attackCooldownRemaining: metadata.attackCooldown,
attackReady: false,
}
}
if (member.attackCooldownRemaining > 0) return member
const damage = Math.min(metadata.attackDamage, target.health)
if (target.kind === 'boss') {
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
} else {
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
? { ...add, health: Math.max(0, add.health - damage) }
: add)
}
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: member.id,
targetId: target.id,
value: damage,
})
if (target.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: member.id,
targetId: target.id,
})
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
}
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
}
})
return {
boss: nextBoss,
party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles,
nextProjectileId: projectileId,
events,
}
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
boss: Iwt2ArenaState['boss'],
hostileAdds: Iwt2HostileAddState[],
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
return livingAdds.reduce((best, add) => (
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return boss.health > 0 ? boss : undefined
}
function addDamageDone(
party: Iwt2PartyEntityState[],
sourceId: Iwt2EntityId,
damage: number,
): Iwt2PartyEntityState[] {
return party.map((member) => member.id === sourceId
? { ...member, damageDone: member.damageDone + damage }
: member)
}
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
return events.map((event, index) => ({ ...event, id: firstId + index }))
}
+148
View File
@@ -0,0 +1,148 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import {
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
tickIwt2Arena as tickCoreIwt2Arena,
} from './arena'
import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
} from './types'
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
export type Iwt2ArenaEntity = {
id: string
kind: Iwt2ArenaEntityKind
icon: string
color: string
x: number
y: number
radius: number
health: number
maxHealth: number
stunnedFor: number
}
export type Iwt2ArenaTelegraph =
| {
kind: 'charge'
x: number
y: number
width: number
height: number
active: boolean
}
| {
kind: 'slam'
x: number
y: number
radius: number
active: boolean
}
export type Iwt2ArenaState = Iwt2CoreArenaState & {
arena: {
width: number
height: number
}
entities: Iwt2ArenaEntity[]
telegraphs: Iwt2ArenaTelegraph[]
}
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
return decorateArenaState(tickCoreIwt2Arena(state, input, dt))
}
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
return {
...state,
arena: { ...state.bounds },
entities: [
...state.party.map(toArenaEntity),
...state.hostileAdds.map(toHostileAddArenaEntity),
toBossArenaEntity(state.boss),
],
telegraphs: createTelegraphs(state.indicators),
}
}
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
return {
id: add.id,
kind: 'hostileAdd',
icon: 'v',
color: '#f0b84f',
x: add.position.x,
y: add.position.y,
radius: add.radius,
health: add.health,
maxHealth: add.maxHealth,
stunnedFor: 0,
}
}
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
kind: member.aiRole === 'player' ? 'player' : 'party',
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
}
}
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
kind: 'boss',
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
stunnedFor: 0,
}
}
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
if (indicator.kind === 'lane') {
return [{
active: indicator.phase === 'active',
height: indicator.width * 2,
kind: 'charge' as const,
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
x: Math.min(indicator.start.x, indicator.end.x),
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
}]
}
if (indicator.kind === 'circle') {
return [{
active: indicator.phase === 'active',
kind: 'slam' as const,
radius: indicator.radius,
x: indicator.position.x,
y: indicator.position.y,
}]
}
return []
})
}
+378
View File
@@ -0,0 +1,378 @@
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2BossTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds?: Iwt2HostileAddState[]
hazards?: Iwt2GroundHazardState[]
projectiles?: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId?: number
nextHazardId?: number
nextProjectileId?: number
}
export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt)
return tickBulldrome(state, dt)
}
export function tickBulldrome(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
const target = getBossTarget(state.party)
let party = state.party
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
}
const wallContactSeconds = isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = { ...boss, wallContactSeconds }
if (boss.health <= 0 || !target) {
return withBulldromeIndicators({ boss: { ...boss, velocity: { x: 0, y: 0 } }, party, events })
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, BULLDROME_BOSS_METADATA.moveSpeed * 1.45 * dt),
boss.radius,
state.bounds,
)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(nextPosition, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.2),
velocity: { x: 0, y: 0 },
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chargeWindup') {
boss = {
...boss,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'charging' }
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'charging') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.chargeEnd, BULLDROME_BOSS_METADATA.chargeSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(velocity, boss.facing),
chargeHitEntityIds: hitResult.hitEntityIds,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) {
const needsSlam = boss.chargeCount % 3 === 0
boss = {
...boss,
attackPhase: needsSlam ? 'slamWindup' : 'chargeRecover',
phaseSecondsRemaining: needsSlam ? BULLDROME_BOSS_METADATA.slamWindup : 0.45,
slamApplied: false,
velocity: { x: 0, y: 0 },
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'slamWindup') {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applySlam(party, boss, state.time + dt)
party = slamResult.party
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: 'slamRecover',
phaseSecondsRemaining: 0.5,
slamApplied: true,
}
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'chargeRecover' || boss.attackPhase === 'slamRecover') {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= 1.1) {
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.8,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withBulldromeIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
const charge = createLinearMovementPlan({
bounds: state.bounds,
from: boss.position,
length: BULLDROME_BOSS_METADATA.chargeLength,
radius: boss.radius,
target: target.position,
})
boss = {
...boss,
attackPhase: 'chargeWindup',
phaseSecondsRemaining: BULLDROME_BOSS_METADATA.chargeWindup,
chargeCooldownRemaining: BULLDROME_BOSS_METADATA.chargeCooldown,
chargeCount: boss.chargeCount + 1,
chargeStart: { ...boss.position },
chargeEnd: charge.end,
chargeHitEntityIds: [],
slamApplied: false,
facing: charge.direction,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(state.nextEventId + events.length, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withBulldromeIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt, state.nextEventId + events.length)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (meleeResult.events.length > 0) return withBulldromeIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, BULLDROME_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0)
boss = {
...boss,
position: nextPosition,
velocity,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withBulldromeIndicators({ boss, party, events })
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState) {
const margin = boss.radius + 10
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = { x: state.bounds.width * 0.58, y: state.bounds.height * 0.5 }
const awayFromWall = {
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
}
return {
x: Math.min(state.bounds.width - boss.radius, Math.max(boss.radius, center.x + awayFromWall.x * 70)),
y: Math.min(state.bounds.height - boss.radius, Math.max(boss.radius, center.y + awayFromWall.y * 54)),
}
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
firstEventId: number,
): Omit<Iwt2BossTickResult, 'indicators'> {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const nextParty = party.map((member) => {
if (member.id !== target.id) return member
return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) }
})
const events = [
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage),
]
if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) {
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
}
return {
boss: { ...boss, meleeCooldownRemaining: BULLDROME_BOSS_METADATA.meleeCooldown, velocity: { x: 0, y: 0 } },
party: nextParty,
events,
}
}
function applyChargeHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(
party,
{
kind: 'lane',
start,
end,
width: boss.radius,
},
{
damage: BULLDROME_BOSS_METADATA.chargeDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds,
sourceId: boss.id,
stunSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds,
time,
},
)
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applySlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(
party,
{
kind: 'circle',
position: boss.position,
radius: BULLDROME_BOSS_METADATA.slamRadius,
},
{
damage: BULLDROME_BOSS_METADATA.slamDamage,
knockdownSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds,
sourceId: boss.id,
stunSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds,
time,
},
)
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, BULLDROME_BOSS_METADATA.slamRadius),
...result.events,
],
}
}
function withBulldromeIndicators(
result: Omit<Iwt2BossTickResult, 'indicators'>,
): Iwt2BossTickResult {
return {
...result,
indicators: createBulldromeIndicators(result.boss),
}
}
function createBulldromeIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (
boss.attackPhase === 'chargeWindup'
|| boss.attackPhase === 'charging'
|| boss.attackPhase === 'chargeRecover'
) {
indicators.push(createLaneIndicator({
color: boss.attackPhase === 'charging' ? '#f05b4f' : '#f1c663',
end: boss.chargeEnd,
id: `${boss.id}:charge-lane`,
mechanicId: 'bulldrome-charge',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'chargeWindup',
boss.attackPhase === 'charging',
),
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover') {
indicators.push(createCircleIndicator({
color: '#ff7f50',
id: `${boss.id}:slam-circle`,
mechanicId: 'bulldrome-slam',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'slamWindup',
boss.attackPhase === 'slamRecover',
),
position: boss.position,
radius: BULLDROME_BOSS_METADATA.slamRadius,
sourceId: boss.id,
}))
}
return indicators
}
+46
View File
@@ -0,0 +1,46 @@
import type { Iwt2ArenaBounds, Iwt2Circle, Iwt2Vec2 } from './types'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export function circlesOverlap(a: Iwt2Circle, b: Iwt2Circle): boolean {
const radiusSum = a.radius + b.radius
return distanceVec2(a.position, b.position) <= radiusSum
}
export function distanceToSegment(point: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): number {
const segment = subtractVec2(end, start)
const segmentLengthSq = lengthSqVec2(segment)
if (segmentLengthSq <= 0.0001) return distanceVec2(point, start)
const pointOffset = subtractVec2(point, start)
const t = clamp(dotVec2(pointOffset, segment) / segmentLengthSq, 0, 1)
const projection = addVec2(start, scaleVec2(segment, t))
return distanceVec2(point, projection)
}
export function circleIntersectsSegment(
circle: Iwt2Circle,
start: Iwt2Vec2,
end: Iwt2Vec2,
width: number,
): boolean {
return distanceToSegment(circle.position, start, end) <= circle.radius + width
}
export function separateCircles(moving: Iwt2Circle, fixed: Iwt2Circle, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
const offset = subtractVec2(moving.position, fixed.position)
const minDistance = moving.radius + fixed.radius
const distanceSq = lengthSqVec2(offset)
if (distanceSq >= minDistance * minDistance) return moving.position
const direction = distanceSq <= 0.0001 ? { x: 1, y: 0 } : normalizeVec2(offset)
const resolved = addVec2(fixed.position, scaleVec2(direction, minDistance))
return clampVec2ToArena(resolved, moving.radius, bounds)
}
+162
View File
@@ -0,0 +1,162 @@
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
} from './mechanics'
const MAX_FIRE_PUDDLES = 8
const FIRE_PUDDLE_TICK_SECONDS = 0.55
const FIRE_PUDDLE_FADE_SECONDS = 0.8
const FIRE_PUDDLE_MERGE_DISTANCE = 28
export function addFirePuddle({
damage,
duration,
hazards,
nextHazardId,
position,
radius,
sourceId,
time,
}: {
damage: number
duration: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
const overlappingIndex = hazards.findIndex((hazard) => (
hazard.hazardKind === 'firePuddle'
&& hazard.remainingSeconds > hazard.fadeSeconds
&& Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE
))
if (overlappingIndex >= 0) {
return {
hazards: hazards.map((hazard, index) => index === overlappingIndex
? {
...hazard,
damage: Math.max(hazard.damage, damage),
position: {
x: (hazard.position.x + position.x) / 2,
y: (hazard.position.y + position.y) / 2,
},
radius: Math.max(hazard.radius, radius),
remainingSeconds: Math.max(hazard.remainingSeconds, duration),
}
: hazard),
nextHazardId,
}
}
const nextHazard: Iwt2GroundHazardState = {
id: `fire-puddle-${nextHazardId}`,
kind: 'groundHazard',
hazardKind: 'firePuddle',
sourceId,
position: { ...position },
radius,
damage,
remainingSeconds: duration,
fadeSeconds: FIRE_PUDDLE_FADE_SECONDS,
nextDamageAt: time + 0.12,
}
return {
hazards: capFirePuddles([...hazards, nextHazard]),
nextHazardId: nextHazardId + 1,
}
}
export function tickGroundHazards({
dt,
hazards,
party,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
party: Iwt2PartyEntityState[]
time: number
}): {
hazards: Iwt2GroundHazardState[]
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextParty = party
const nextHazards: Iwt2GroundHazardState[] = []
for (const hazard of hazards) {
const remainingSeconds = hazard.remainingSeconds - dt
if (remainingSeconds <= 0) continue
let nextDamageAt = hazard.nextDamageAt
if (time >= hazard.nextDamageAt) {
const result = applyPartyDamageInShape(
nextParty,
{
kind: 'circle',
position: hazard.position,
radius: hazard.radius,
},
{
damage: hazard.damage,
sourceId: hazard.sourceId,
time,
},
)
nextParty = result.party
events.push(...result.events)
nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS
}
nextHazards.push({
...hazard,
nextDamageAt,
remainingSeconds,
})
}
return {
events,
hazards: nextHazards,
party: nextParty,
}
}
export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] {
return hazards.map((hazard) => createCircleIndicator({
color: '#ff7a2f',
id: `${hazard.id}:indicator`,
mechanicId: 'fire-puddle',
phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active',
position: hazard.position,
radius: hazard.radius,
sourceId: hazard.sourceId,
}))
}
function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] {
let activePuddles = 0
let oldestActivePuddleIndex = -1
for (let index = 0; index < hazards.length; index += 1) {
const hazard = hazards[index]
if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue
activePuddles += 1
if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index
}
if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards
return hazards.map((hazard, index) => index === oldestActivePuddleIndex
? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) }
: hazard)
}
+124
View File
@@ -0,0 +1,124 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import type {
Iwt2ArenaEvent,
Iwt2ArenaState,
Iwt2EntityId,
Iwt2PartyEntityState,
} from './types'
import { createArenaEvent } from './mechanics'
const HEALER_ID: Iwt2EntityId = 'player-healer'
const MAX_EVENTS = 80
const DEFAULT_HOT_SECONDS = 8
const DEFAULT_HOT_TICK_SECONDS = 1
export type Iwt2HealerCastResult<TState extends Iwt2ArenaState = Iwt2ArenaState> = {
cast: boolean
state: TState
}
export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
state: TState,
ability: Iwt2HealerAbility,
selectedTargetId: Iwt2EntityId,
): Iwt2HealerCastResult<TState> {
if (state.boss.health <= 0) return { cast: false, state }
const healer = state.party.find((member) => member.id === HEALER_ID)
if (!healer || healer.health <= 0 || healer.mana < ability.manaCost) return { cast: false, state }
const targetIds = targetIdsForAbility(state.party, ability, selectedTargetId)
if (targetIds.length === 0) return { cast: false, state }
const events: Iwt2ArenaEvent[] = []
let eventId = state.nextEventId
const targetIdSet = new Set(targetIds)
const nextParty = state.party.map((member) => {
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
if (!targetIdSet.has(member.id)) return { ...member, mana }
const nextMember = applyAbilityToMember(member, ability)
const healed = Math.max(0, nextMember.health - member.health)
if (healed > 0) {
events.push(createArenaEvent(eventId, state.time, 'partyHealed', HEALER_ID, member.id, healed))
eventId += 1
}
return { ...nextMember, mana }
})
return {
cast: true,
state: {
...state,
party: nextParty,
nextEventId: eventId,
events: [...state.events, ...events].slice(-MAX_EVENTS),
} as TState,
}
}
function targetIdsForAbility(
party: Iwt2PartyEntityState[],
ability: Iwt2HealerAbility,
selectedTargetId: Iwt2EntityId,
): Iwt2EntityId[] {
const living = party.filter((member) => member.health > 0)
if (living.length === 0) return []
if (ability.kind === 'group') {
return [...living]
.sort((a, b) => healthRatio(a) - healthRatio(b))
.slice(0, 4)
.map((member) => member.id)
}
const selected = living.find((member) => member.id === selectedTargetId)
return [selected?.id ?? living[0].id]
}
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
if (ability.kind === 'hot') {
return {
...healMember(member, ability.power),
hotEffects: [
...member.hotEffects.filter((effect) => effect.id !== ability.id),
{
id: ability.id,
label: ability.name,
power: Math.max(1, Math.round(ability.power * 0.5)),
remainingSeconds: DEFAULT_HOT_SECONDS,
tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS,
nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS,
},
],
}
}
if (ability.kind === 'shield') {
return {
...member,
shield: Math.max(member.shield, ability.power),
}
}
if (ability.kind === 'cleanse') {
return {
...healMember(member, ability.power),
status: {
...member.status,
knockedDownSeconds: 0,
stunnedSeconds: 0,
},
}
}
return healMember(member, ability.power)
}
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
return {
...member,
health: Math.min(member.maxHealth, member.health + amount),
}
}
function healthRatio(member: Iwt2PartyEntityState) {
return member.health / member.maxHealth
}
+67
View File
@@ -0,0 +1,67 @@
export {
createInitialIwt2ArenaState,
tickIwt2Arena,
} from './arena'
export {
circlesOverlap,
circleIntersectsSegment,
distanceToSegment,
separateCircles,
} from './collision'
export {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createConeIndicator,
createDonutIndicator,
createLaneIndicator,
createLinearMovementPlan,
indicatorPhaseFromAttack,
isIndicatorActive,
partyMemberIntersectsShape,
} from './mechanics'
export {
castIwt2HealerAbility,
} from './healing'
export {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
lengthVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export type {
Iwt2ArenaBounds,
Iwt2ArenaEvent,
Iwt2ArenaEventType,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2BossAttackPhase,
Iwt2BossEntityState,
Iwt2Circle,
Iwt2EntityId,
Iwt2EntityKind,
Iwt2GroundHazardState,
Iwt2HotEffectState,
Iwt2HostileAddAttackPhase,
Iwt2HostileAddState,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
+308
View File
@@ -0,0 +1,308 @@
import type {
Iwt2ArenaBounds,
Iwt2ArenaCircleIndicator,
Iwt2ArenaConeIndicator,
Iwt2ArenaDonutIndicator,
Iwt2ArenaEvent,
Iwt2ArenaEventType,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaLaneIndicator,
Iwt2EntityId,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { circleIntersectsSegment } from './collision'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
} from './vector'
export type Iwt2LinearMovementPlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
}
export type Iwt2PartyDamageEffect = {
sourceId: Iwt2EntityId
time: number
damage: number
damageEventType?: Iwt2ArenaEventType
stunSeconds?: number
knockdownSeconds?: number
statusEventType?: Iwt2ArenaEventType
excludedEntityIds?: Iwt2EntityId[]
}
export type Iwt2LaneHitShape = {
kind: 'lane'
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2CircleHitShape = {
kind: 'circle'
position: Iwt2Vec2
radius: number
}
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape
export function createArenaEvent(
id: number,
time: number,
type: Iwt2ArenaEventType,
sourceId: Iwt2EntityId,
targetId?: Iwt2EntityId,
value?: number,
): Iwt2ArenaEvent {
return { id, time, type, sourceId, targetId, value }
}
export function createLinearMovementPlan({
bounds,
from,
length,
radius,
target,
}: {
bounds: Iwt2ArenaBounds
from: Iwt2Vec2
length: number
radius: number
target: Iwt2Vec2
}): Iwt2LinearMovementPlan {
const direction = normalizeVec2(subtractVec2(target, from))
const rawEnd = addVec2(from, scaleVec2(direction, length))
return {
direction,
end: clampVec2ToArena(rawEnd, radius, bounds),
}
}
export function applyPartyDamageInShape(
party: Iwt2PartyEntityState[],
shape: Iwt2HitShape,
effect: Iwt2PartyDamageEffect,
): {
party: Iwt2PartyEntityState[]
hitEntityIds: Iwt2EntityId[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const excluded = new Set(effect.excludedEntityIds ?? [])
const hitEntityIds: Iwt2EntityId[] = []
const stunSeconds = effect.stunSeconds ?? 0
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
const nextParty = party.map((member) => {
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
return member
}
hitEntityIds.push(member.id)
const shieldAbsorbed = Math.min(member.shield, effect.damage)
const healthDamage = effect.damage - shieldAbsorbed
const nextShield = member.shield - shieldAbsorbed
const nextHealth = Math.max(0, member.health - healthDamage)
events.push(createArenaEvent(
0,
effect.time,
effect.damageEventType ?? 'partyDamaged',
effect.sourceId,
member.id,
effect.damage,
))
if (stunSeconds > 0 || knockdownSeconds > 0) {
events.push(createArenaEvent(
0,
effect.time,
effect.statusEventType ?? 'partyStunned',
effect.sourceId,
member.id,
Math.max(stunSeconds, knockdownSeconds),
))
}
if (member.health > 0 && nextHealth <= 0) {
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
}
return {
...member,
health: nextHealth,
shield: nextShield,
status: {
...member.status,
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
knockedDownSeconds: Math.max(member.status.knockedDownSeconds, knockdownSeconds),
},
}
})
return { party: nextParty, hitEntityIds, events }
}
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
if (shape.kind === 'lane') {
return circleIntersectsSegment(
{ position: member.position, radius: member.radius },
shape.start,
shape.end,
shape.width,
)
}
return distanceVec2(member.position, shape.position) <= shape.radius + member.radius
}
export function createLaneIndicator({
color,
end,
id,
mechanicId,
phase,
sourceId,
start,
width,
}: {
color: string
end: Iwt2Vec2
id: string
mechanicId: string
phase: Iwt2ArenaIndicatorPhase
sourceId: Iwt2EntityId
start: Iwt2Vec2
width: number
}): Iwt2ArenaLaneIndicator {
return {
color,
end: { ...end },
fillAlpha: phase === 'active' ? 0.18 : 0.11,
id,
kind: 'lane',
lineAlpha: phase === 'active' ? 0.8 : 0.45,
mechanicId,
phase,
sourceId,
start: { ...start },
width,
}
}
export function createCircleIndicator({
color,
id,
mechanicId,
phase,
position,
radius,
sourceId,
}: {
color: string
id: string
mechanicId: string
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
radius: number
sourceId: Iwt2EntityId
}): Iwt2ArenaCircleIndicator {
return {
color,
fillAlpha: phase === 'active' ? 0.18 : 0.12,
id,
kind: 'circle',
lineAlpha: phase === 'active' ? 0.75 : 0.55,
mechanicId,
phase,
position: { ...position },
radius,
sourceId,
}
}
export function createConeIndicator({
angleRadians,
color,
direction,
id,
mechanicId,
origin,
phase,
range,
sourceId,
}: {
angleRadians: number
color: string
direction: Iwt2Vec2
id: string
mechanicId: string
origin: Iwt2Vec2
phase: Iwt2ArenaIndicatorPhase
range: number
sourceId: Iwt2EntityId
}): Iwt2ArenaConeIndicator {
return {
angleRadians,
color,
direction: normalizeVec2(direction),
fillAlpha: phase === 'active' ? 0.18 : 0.1,
id,
kind: 'cone',
lineAlpha: phase === 'active' ? 0.72 : 0.48,
mechanicId,
origin: { ...origin },
phase,
range,
sourceId,
}
}
export function createDonutIndicator({
color,
id,
innerRadius,
mechanicId,
outerRadius,
phase,
position,
sourceId,
}: {
color: string
id: string
innerRadius: number
mechanicId: string
outerRadius: number
phase: Iwt2ArenaIndicatorPhase
position: Iwt2Vec2
sourceId: Iwt2EntityId
}): Iwt2ArenaDonutIndicator {
return {
color,
fillAlpha: phase === 'active' ? 0.14 : 0.08,
id,
innerRadius,
kind: 'donut',
lineAlpha: phase === 'active' ? 0.76 : 0.5,
mechanicId,
outerRadius,
phase,
position: { ...position },
sourceId,
}
}
export function indicatorPhaseFromAttack(
windup: boolean,
active: boolean,
): Iwt2ArenaIndicatorPhase {
if (active) return 'active'
if (windup) return 'windup'
return 'recover'
}
export function isIndicatorActive(indicator: Iwt2ArenaIndicator): boolean {
return indicator.phase === 'active'
}
+230
View File
@@ -0,0 +1,230 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
dotVec2,
lengthSqVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickPartyMember(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
inputMove: Iwt2Vec2,
dt: number,
): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[member.classId]
const status = {
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
}
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
if (member.health <= 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) {
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
}
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
y: member.position.y + velocity.y * dt,
},
member.radius,
state.bounds,
)
return {
...member,
position,
velocity,
facing: withFallbackFacing(velocity, member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
}
}
const dangerDestination = getDangerAvoidancePosition(member, state)
const attackTarget = getPriorityAttackTarget(member, state)
const canCast = member.aiRole === 'ranged'
&& attackCooldownRemaining <= 0
&& !!attackTarget
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
: metadata.castTime
return {
...member,
velocity: { x: 0, y: 0 },
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: nextCastSecondsRemaining,
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
status,
}
}
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
const maxDistance = metadata.moveSpeed * dt
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
return {
...member,
position,
velocity,
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
attackCooldownRemaining,
castSecondsRemaining: 0,
attackReady: false,
status,
decisionSecondsRemaining: decisionSecondsRemaining <= 0
? nextDecisionInterval(member)
: decisionSecondsRemaining,
}
}
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
}
function getPartyDesiredPosition(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
decisionSecondsRemaining: number,
): Iwt2Vec2 {
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
const attackTarget = getPriorityAttackTarget(member, state)
const anchor = attackTarget?.position ?? state.boss.position
return {
x: anchor.x + member.preferredOffset.x + drift.x,
y: anchor.y + member.preferredOffset.y + drift.y,
}
}
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
const boss = state.boss
const hazardEscape = getHazardAvoidancePosition(member, state)
if (hazardEscape) {
return hazardEscape
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
const distance = distanceVec2(member.position, boss.position)
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
if (distance < dangerRadius) {
const away = normalizeVec2(subtractVec2(member.position, boss.position))
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
}
}
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
const danger = chargeDanger(member.position, state)
if (danger.inside || danger.ahead) {
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
return clampVec2ToArena(escape, member.radius, state.bounds)
}
}
return null
}
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
let escape = { x: 0, y: 0 }
let maxNeededDistance = 0
for (const hazard of state.hazards) {
const offset = subtractVec2(member.position, hazard.position)
const distance = distanceVec2(member.position, hazard.position)
const dangerRadius = hazard.radius + member.radius + 46
if (distance >= dangerRadius) continue
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
})
const away = distance > 0.001 ? normalizeVec2(offset) : fallback
const urgency = dangerRadius - distance
escape = addVec2(escape, scaleVec2(away, urgency))
maxNeededDistance = Math.max(maxNeededDistance, urgency)
}
if (maxNeededDistance <= 0) return null
const direction = withFallbackFacing(escape, {
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
y: 0,
})
return clampVec2ToArena(
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
member.radius,
state.bounds,
)
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
state: Iwt2ArenaState,
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
if (member.health <= 0) return undefined
const livingAdds = state.hostileAdds.filter((add) => add.health > 0)
if (livingAdds.length > 0) {
return livingAdds.reduce((best, add) => (
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
), livingAdds[0])
}
return state.boss.health > 0 ? state.boss : undefined
}
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
const boss = state.boss
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
const lengthSq = Math.max(1, lengthSqVec2(segment))
const direction = normalizeVec2(segment)
const offset = subtractVec2(position, boss.chargeStart)
const along = dotVec2(offset, segment) / lengthSq
const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along))))
const distance = distanceVec2(position, closest)
const width = boss.radius + 22
return {
direction,
inside: along >= -0.08 && along <= 1.08 && distance <= width,
ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38,
}
}
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
const seed = member.id.length * 17
return {
x: Math.sin(time * 0.7 + seed) * 16,
y: Math.cos(time * 0.9 + seed) * 14,
}
}
function nextDecisionInterval(member: Iwt2PartyEntityState): number {
if (member.aiRole === 'tank') return 0.34
if (member.aiRole === 'ranged') return 0.48
if (member.aiRole === 'flanker') return 0.28
return 0.38
}
+254
View File
@@ -0,0 +1,254 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
export type Iwt2Vec2 = {
x: number
y: number
}
export type Iwt2ArenaBounds = {
width: number
height: number
}
export type Iwt2PartyEntityId =
| 'player-healer'
| 'paladin-tank'
| 'ranger'
| 'mage'
| 'rogue'
| 'warrior'
export type Iwt2BossEntityId = Iwt2BossId
export type Iwt2EntityId = Iwt2PartyEntityId | Iwt2BossEntityId | string
export type Iwt2EntityKind = 'party' | 'boss' | 'hostileAdd'
export type Iwt2PartyAiRole = 'player' | 'tank' | 'ranged' | 'flanker' | 'melee'
export type Iwt2StatusState = {
stunnedSeconds: number
knockedDownSeconds: number
invulnerableSeconds: number
}
export type Iwt2HotEffectState = {
id: string
label: string
power: number
remainingSeconds: number
tickIntervalSeconds: number
nextTickInSeconds: number
}
export type Iwt2PartyEntityState = {
id: Iwt2PartyEntityId
kind: 'party'
classId: Iwt2PlayerClassId
aiRole: Iwt2PartyAiRole
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
shield: number
mana: number
maxMana: number
damageDone: number
attackCooldownRemaining: number
castSecondsRemaining: number
attackReady: boolean
status: Iwt2StatusState
hotEffects: Iwt2HotEffectState[]
preferredOffset: Iwt2Vec2
decisionSecondsRemaining: number
}
export type Iwt2ProjectileEntityState = {
id: string
sourceId: Iwt2EntityId
owner: 'party' | 'boss'
classId?: Iwt2PlayerClassId
projectileKind: 'arrow' | 'fireball' | 'magic'
color: string
position: Iwt2Vec2
velocity: Iwt2Vec2
radius: number
damage: number
remainingSeconds: number
bouncesRemaining?: number
bounceCooldownSeconds?: number
}
export type Iwt2BossAttackPhase =
| 'idle'
| 'chargeWindup'
| 'charging'
| 'chargeRecover'
| 'slamWindup'
| 'slamRecover'
| 'relocating'
| 'fireballWindup'
| 'fireballRecover'
| 'birdSummonWindup'
| 'birdSummonRecover'
export type Iwt2HostileAddAttackPhase =
| 'idle'
| 'flightWindup'
| 'flying'
| 'flightRecover'
export type Iwt2BossEntityState = {
id: Iwt2BossEntityId
kind: 'boss'
bossId: Iwt2BossId
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
meleeCooldownRemaining: number
chargeCooldownRemaining: number
chargeCount: number
attackPhase: Iwt2BossAttackPhase
phaseSecondsRemaining: number
chargeStart: Iwt2Vec2
chargeEnd: Iwt2Vec2
chargeHitEntityIds: Iwt2EntityId[]
slamApplied: boolean
wallContactSeconds: number
relocateTarget: Iwt2Vec2
fireballCooldownRemaining: number
fireballTarget: Iwt2Vec2
birdWaveThresholdsTriggered: number[]
}
export type Iwt2HostileAddState = {
id: string
kind: 'hostileAdd'
addKind: 'yian-bird'
sourceBossId: Iwt2BossId
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
health: number
maxHealth: number
damageDone: number
attackCooldownRemaining: number
flightCooldownRemaining: number
attackPhase: Iwt2HostileAddAttackPhase
phaseSecondsRemaining: number
flightStart: Iwt2Vec2
flightEnd: Iwt2Vec2
flightHitEntityIds: Iwt2EntityId[]
}
export type Iwt2GroundHazardState = {
id: string
kind: 'groundHazard'
hazardKind: 'firePuddle'
sourceId: Iwt2EntityId
position: Iwt2Vec2
radius: number
damage: number
remainingSeconds: number
fadeSeconds: number
nextDamageAt: number
}
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
| 'bossChargeStart'
| 'bossChargeHit'
| 'bossSlam'
| 'entityDefeated'
export type Iwt2ArenaEvent = {
id: number
time: number
type: Iwt2ArenaEventType
sourceId: Iwt2EntityId
targetId?: Iwt2EntityId
value?: number
}
export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
export type Iwt2ArenaIndicatorBase = {
id: string
mechanicId: string
sourceId: Iwt2EntityId
phase: Iwt2ArenaIndicatorPhase
color: string
fillAlpha?: number
lineAlpha?: number
}
export type Iwt2ArenaLaneIndicator = Iwt2ArenaIndicatorBase & {
kind: 'lane'
start: Iwt2Vec2
end: Iwt2Vec2
width: number
}
export type Iwt2ArenaCircleIndicator = Iwt2ArenaIndicatorBase & {
kind: 'circle'
position: Iwt2Vec2
radius: number
}
export type Iwt2ArenaConeIndicator = Iwt2ArenaIndicatorBase & {
kind: 'cone'
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
kind: 'donut'
position: Iwt2Vec2
innerRadius: number
outerRadius: number
}
export type Iwt2ArenaIndicator =
| Iwt2ArenaLaneIndicator
| Iwt2ArenaCircleIndicator
| Iwt2ArenaConeIndicator
| Iwt2ArenaDonutIndicator
export type Iwt2ArenaState = {
schemaVersion: 1
time: number
bounds: Iwt2ArenaBounds
party: Iwt2PartyEntityState[]
projectiles: Iwt2ProjectileEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
boss: Iwt2BossEntityState
indicators: Iwt2ArenaIndicator[]
nextEventId: number
nextProjectileId: number
nextAddId: number
nextHazardId: number
events: Iwt2ArenaEvent[]
}
export type Iwt2ArenaInput = {
moveX: number
moveY: number
}
export type Iwt2Circle = {
position: Iwt2Vec2
radius: number
}
+60
View File
@@ -0,0 +1,60 @@
import type { Iwt2ArenaBounds, Iwt2Vec2 } from './types'
export const IWT2_ZERO_VECTOR: Iwt2Vec2 = { x: 0, y: 0 }
export function addVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
return { x: a.x + b.x, y: a.y + b.y }
}
export function subtractVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
return { x: a.x - b.x, y: a.y - b.y }
}
export function scaleVec2(vector: Iwt2Vec2, scale: number): Iwt2Vec2 {
return { x: vector.x * scale, y: vector.y * scale }
}
export function dotVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
return a.x * b.x + a.y * b.y
}
export function lengthSqVec2(vector: Iwt2Vec2): number {
return dotVec2(vector, vector)
}
export function lengthVec2(vector: Iwt2Vec2): number {
return Math.sqrt(lengthSqVec2(vector))
}
export function distanceVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
return lengthVec2(subtractVec2(a, b))
}
export function normalizeVec2(vector: Iwt2Vec2): Iwt2Vec2 {
const length = lengthVec2(vector)
if (length <= 0.0001) return { ...IWT2_ZERO_VECTOR }
return { x: vector.x / length, y: vector.y / length }
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function clampVec2ToArena(position: Iwt2Vec2, radius: number, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
return {
x: clamp(position.x, radius, bounds.width - radius),
y: clamp(position.y, radius, bounds.height - radius),
}
}
export function moveToward(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = lengthVec2(offset)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
export function withFallbackFacing(nextFacing: Iwt2Vec2, fallback: Iwt2Vec2): Iwt2Vec2 {
if (lengthSqVec2(nextFacing) <= 0.0001) return { ...fallback }
return normalizeVec2(nextFacing)
}
+707
View File
@@ -0,0 +1,707 @@
import { YIAN_KUT_KU_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
partyMemberIntersectsShape,
} from './mechanics'
import { addFirePuddle } from './hazards'
import {
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export type Iwt2YianTickResult = {
boss: Iwt2BossEntityState
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
indicators: Iwt2ArenaIndicator[]
nextAddId: number
nextHazardId: number
nextProjectileId: number
}
const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 10
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36
const YIAN_CENTER_CHARGE_SPEED = 430
export function tickYianKutKu(state: Iwt2ArenaState, dt: number): Iwt2YianTickResult {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let party = state.party
let hazards = state.hazards
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
let nextProjectileId = state.nextProjectileId
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const birdResult = tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
party = birdResult.party
hazards = birdResult.hazards
nextHazardId = birdResult.nextHazardId
events.push(...birdResult.events)
let hostileAdds = birdResult.hostileAdds
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'relocating') {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, YIAN_CENTER_CHARGE_SPEED * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
if (distanceVec2(nextPosition, boss.relocateTarget) <= 4 || boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const direction = withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing)
projectiles.push({
id: `boss-projectile-${nextProjectileId}`,
sourceId: boss.id,
owner: 'boss',
projectileKind: 'fireball',
color: '#ff8b2b',
position: {
x: boss.position.x + direction.x * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
y: boss.position.y + direction.y * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
},
velocity: scaleVec2(direction, YIAN_KUT_KU_BOSS_METADATA.fireballSpeed!),
radius: YIAN_KUT_KU_BOSS_METADATA.fireballRadius!,
damage: YIAN_KUT_KU_BOSS_METADATA.fireballDamage!,
remainingSeconds: 7,
bouncesRemaining: YIAN_FIREBALL_BOUNCES,
})
nextProjectileId += 1
boss = {
...boss,
attackPhase: 'fireballRecover',
phaseSecondsRemaining: 0.35,
}
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.attackPhase === 'fireballRecover' || boss.attackPhase === 'birdSummonRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (shouldYianRelocate(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextThreshold = nextBirdWaveThreshold(boss)
if (nextThreshold !== undefined && boss.health / boss.maxHealth <= nextThreshold) {
const spawn = spawnBirdWave(state, nextAddId, boss.id, nextThreshold)
hostileAdds = [...hostileAdds, ...spawn.hostileAdds]
nextAddId = spawn.nextAddId
boss = {
...boss,
attackPhase: 'birdSummonRecover',
birdWaveThresholdsTriggered: [...boss.birdWaveThresholdsTriggered, nextThreshold],
phaseSecondsRemaining: 0.55,
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
if (boss.fireballCooldownRemaining <= 0) {
if (!isYianAtCenter(boss, state)) {
const center = yianCenterTarget(state)
boss = {
...boss,
attackPhase: 'relocating',
phaseSecondsRemaining: 1.4,
relocateTarget: center,
chargeStart: { ...boss.position },
chargeEnd: center,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
boss = {
...boss,
attackPhase: 'fireballWindup',
fireballCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballCooldown!,
fireballTarget: { ...target.position },
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballWindup!,
velocity: { x: 0, y: 0 },
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
boss = meleeResult.boss
events.push(...meleeResult.events)
if (meleeResult.events.length > 0) {
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
const nextPosition = clampVec2ToArena(
moveToward(boss.position, yianApproachTarget(target, state), YIAN_KUT_KU_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withYianIndicators({
boss,
events,
hazards,
hostileAdds,
nextAddId,
nextHazardId,
nextProjectileId,
party,
projectiles,
})
}
function shouldYianRelocate(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
): boolean {
return (
boss.position.x < YIAN_SAFE_WALL_MARGIN
|| boss.position.x > state.bounds.width - YIAN_SAFE_WALL_MARGIN
|| boss.position.y < YIAN_SAFE_WALL_MARGIN
|| boss.position.y > state.bounds.height - YIAN_SAFE_WALL_MARGIN
)
}
function isYianAtCenter(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return distanceVec2(boss.position, yianCenterTarget(state)) <= YIAN_CENTER_CAST_DISTANCE
}
function yianApproachTarget(target: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
return clampVec2ToArena({
x: target.position.x + (target.position.x < state.bounds.width * 0.5 ? 190 : -190),
y: target.position.y + (target.position.y < state.bounds.height * 0.5 ? 96 : -96),
}, YIAN_KUT_KU_BOSS_METADATA.radius, state.bounds)
}
function yianCenterTarget(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function tickYianBirds({
dt,
hazards,
nextHazardId,
party,
state,
time,
}: {
dt: number
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
hostileAdds: Iwt2HostileAddState[]
party: Iwt2PartyEntityState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const hostileAdds: Iwt2HostileAddState[] = []
let nextParty = party
let nextHazards = hazards
let nextHazardIdValue = nextHazardId
for (const add of state.hostileAdds) {
if (add.health <= 0) continue
let nextAdd = {
...add,
attackCooldownRemaining: Math.max(0, add.attackCooldownRemaining - dt),
flightCooldownRemaining: Math.max(0, add.flightCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, add.phaseSecondsRemaining - dt),
}
if (nextAdd.attackPhase === 'flightWindup') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = {
...nextAdd,
attackPhase: 'flying',
position: { ...nextAdd.flightStart },
velocity: scaleVec2(normalizeVec2(subtractVec2(nextAdd.flightEnd, nextAdd.flightStart)), YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed!),
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flying') {
const previousPosition = nextAdd.position
const nextPosition = moveToward(
nextAdd.position,
nextAdd.flightEnd,
YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed! * dt,
)
const shape = {
kind: 'lane' as const,
start: previousPosition,
end: nextPosition,
width: nextAdd.radius,
}
const hitMembers = nextParty.filter((member) => (
!nextAdd.flightHitEntityIds.includes(member.id)
&& partyMemberIntersectsShape(member, shape)
))
const hitResult = applyPartyDamageInShape(nextParty, shape, {
damage: BIRD_FLIGHT_DAMAGE,
excludedEntityIds: nextAdd.flightHitEntityIds,
knockdownSeconds: 0,
sourceId: nextAdd.id,
stunSeconds: YIAN_KUT_KU_BOSS_METADATA.birdStunSeconds!,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
for (const member of hitMembers) {
const puddle = addFirePuddle({
damage: YIAN_KUT_KU_BOSS_METADATA.firePuddleDamage!,
duration: YIAN_KUT_KU_BOSS_METADATA.firePuddleSeconds!,
hazards: nextHazards,
nextHazardId: nextHazardIdValue,
position: member.position,
radius: YIAN_KUT_KU_BOSS_METADATA.firePuddleRadius!,
sourceId: nextAdd.id,
time,
})
nextHazards = puddle.hazards
nextHazardIdValue = puddle.nextHazardId
}
nextAdd = {
...nextAdd,
flightHitEntityIds: [...nextAdd.flightHitEntityIds, ...hitResult.hitEntityIds],
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, previousPosition), dt > 0 ? 1 / dt : 0),
facing: { x: 1, y: 0 },
}
if (distanceVec2(nextPosition, nextAdd.flightEnd) <= 1) {
nextAdd = {
...nextAdd,
attackPhase: 'flightRecover',
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
phaseSecondsRemaining: 0.4,
velocity: { x: 0, y: 0 },
}
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.attackPhase === 'flightRecover') {
if (nextAdd.phaseSecondsRemaining <= 0) {
nextAdd = { ...nextAdd, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
hostileAdds.push(nextAdd)
continue
}
if (nextAdd.flightCooldownRemaining <= 0) {
hostileAdds.push(startBirdFlight(nextAdd, state, time))
continue
}
const target = getNearestPartyMember(nextParty, nextAdd.position)
if (!target) {
hostileAdds.push({ ...nextAdd, velocity: { x: 0, y: 0 } })
continue
}
const nextPosition = clampVec2ToArena(
moveToward(nextAdd.position, target.position, YIAN_KUT_KU_BOSS_METADATA.moveSpeed * 1.35 * dt),
nextAdd.radius,
state.bounds,
)
nextAdd = {
...nextAdd,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, nextAdd.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), nextAdd.facing),
}
if (
nextAdd.attackCooldownRemaining <= 0
&& distanceVec2(nextAdd.position, target.position) <= nextAdd.radius + target.radius + BIRD_MELEE_RANGE
) {
const hitResult = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: nextAdd.position,
radius: nextAdd.radius + BIRD_MELEE_RANGE,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.birdContactDamage!,
sourceId: nextAdd.id,
time,
})
nextParty = hitResult.party
events.push(...hitResult.events)
nextAdd = { ...nextAdd, attackCooldownRemaining: BIRD_MELEE_COOLDOWN }
}
hostileAdds.push(nextAdd)
}
return {
events,
hazards: nextHazards,
hostileAdds,
nextHazardId: nextHazardIdValue,
party: nextParty,
}
}
function spawnBirdWave(
state: Iwt2ArenaState,
nextAddId: number,
sourceBossId: Iwt2BossId,
threshold: number,
): { hostileAdds: Iwt2HostileAddState[], nextAddId: number } {
const hostileAdds: Iwt2HostileAddState[] = []
let nextId = nextAddId
for (let index = 0; index < BIRD_COUNT; index += 1) {
const y = birdLaneY(state, index, threshold * 100)
const radius = YIAN_KUT_KU_BOSS_METADATA.birdRadius!
const flightStart = { x: -radius - 8, y }
const flightEnd = { x: state.bounds.width + radius + 8, y }
hostileAdds.push({
id: `yian-bird-${nextId}`,
kind: 'hostileAdd',
addKind: 'yian-bird',
sourceBossId,
position: { ...flightStart },
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius,
health: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
maxHealth: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
damageDone: 0,
attackCooldownRemaining: 0,
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
})
nextId += 1
}
return { hostileAdds, nextAddId: nextId }
}
function startBirdFlight(add: Iwt2HostileAddState, state: Iwt2ArenaState, time: number): Iwt2HostileAddState {
const y = birdLaneY(state, Number(add.id.replace(/\D/g, '')) % BIRD_COUNT, time)
const flightStart = { x: -add.radius - 8, y }
const flightEnd = { x: state.bounds.width + add.radius + 8, y }
return {
...add,
attackPhase: 'flightWindup',
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
flightStart,
flightEnd,
flightHitEntityIds: [],
velocity: { x: 0, y: 0 },
}
}
function birdLaneY(state: Iwt2ArenaState, index: number, seed: number): number {
const laneHeight = state.bounds.height / (BIRD_COUNT + 1)
const base = laneHeight * (index + 1)
const wobble = Math.sin(seed * 8.37 + index * 2.1) * 34
return clamp(base + wobble, 58, state.bounds.height - 58)
}
function nextBirdWaveThreshold(boss: Iwt2BossEntityState): number | undefined {
return YIAN_KUT_KU_BOSS_METADATA.birdWaveThresholds
?.find((threshold) => !boss.birdWaveThresholdsTriggered.includes(threshold))
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function getNearestPartyMember(party: Iwt2PartyEntityState[], position: Iwt2Vec2): Iwt2PartyEntityState | undefined {
let nearest: Iwt2PartyEntityState | undefined
let nearestDistance = Number.POSITIVE_INFINITY
for (const member of party) {
if (member.health <= 0) continue
const distance = distanceVec2(member.position, position)
if (distance < nearestDistance) {
nearest = member
nearestDistance = distance
}
}
return nearest
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > YIAN_KUT_KU_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: YIAN_KUT_KU_BOSS_METADATA.meleeRange,
}, {
damage: YIAN_KUT_KU_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withYianIndicators(result: Omit<Iwt2YianTickResult, 'indicators'>): Iwt2YianTickResult {
return {
...result,
indicators: createYianIndicators(result.boss, result.hostileAdds),
}
}
function createYianIndicators(
boss: Iwt2BossEntityState,
hostileAdds: Iwt2HostileAddState[],
): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'relocating') {
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: boss.chargeEnd,
id: `${boss.id}:center-charge`,
mechanicId: 'yian-center-charge',
phase: 'active',
sourceId: boss.id,
start: boss.chargeStart,
width: boss.radius,
}))
}
if (boss.attackPhase === 'fireballWindup') {
indicators.push(createCircleIndicator({
color: '#ff8b2b',
id: `${boss.id}:fireball-cast`,
mechanicId: 'yian-fireball-cast',
phase: 'windup',
position: boss.position,
radius: boss.radius + 18,
sourceId: boss.id,
}))
indicators.push(createLaneIndicator({
color: '#ffb347',
end: boss.fireballTarget,
id: `${boss.id}:fireball-line`,
mechanicId: 'yian-fireball-line',
phase: 'windup',
sourceId: boss.id,
start: boss.position,
width: YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 4,
}))
}
for (const add of hostileAdds) {
if (
add.attackPhase !== 'flightWindup'
&& add.attackPhase !== 'flying'
&& add.attackPhase !== 'flightRecover'
) {
continue
}
indicators.push(createLaneIndicator({
color: '#ffce5c',
end: add.flightEnd,
id: `${add.id}:flight-lane`,
mechanicId: 'yian-bird-flight',
phase: indicatorPhaseFromAttack(
add.attackPhase === 'flightWindup',
add.attackPhase === 'flying',
),
sourceId: add.id,
start: add.flightStart,
width: add.radius,
}))
}
return indicators
}