Compare commits

...
5 Commits
Author SHA1 Message Date
Warren H 99e97e5208 Android build v1.1.15 2026-07-04 12:43:59 -04:00
Warren H a849cd2c69 Android build v1.1.14 2026-07-03 12:58:53 -04:00
Warren H a121b2ef74 Android build v1.1.13 2026-07-03 01:01:32 -04:00
Warren H b7cb572631 Android build v1.1.12 2026-07-03 00:28:38 -04:00
Warren H c6e2c61d0a Android build v1.1.11 2026-07-02 23:03:52 -04:00
84 changed files with 10232 additions and 1369 deletions
+16
View File
@@ -11,6 +11,7 @@
## Input Requirements
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
- Any time a screen, menu, dialog, overlay, or gameplay interaction is added or changed, verify controller navigation still works for that surface before considering the work complete.
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
- Focus state must always be visible and predictable when controller navigation is active.
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
@@ -22,6 +23,21 @@
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
- Apply game changes to both web version and mobile app version.
## I Want To Heal 1 vs I Want To Heal 2
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
- 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.
## Performance Requirements
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 89
versionName "1.1.10"
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.
+2 -1
View File
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS account_ip_allowances (
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"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+6
View File
@@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'last_saved_at', 'TEXT')
database.prepare(`
UPDATE accounts
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
migrateCharacterAccountConstraint()
+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
+78 -22
View File
@@ -37,6 +37,35 @@ const pvpMatches = new Map()
const pvpQueueTtlMs = 15 * 1000
const pvpMatchTtlMs = 60 * 60 * 1000
function addColumnIfMissing(database, table, column, definition) {
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
if (!columns.some((candidate) => candidate.name === column)) {
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
}
}
function ensureRuntimeMigrations(database) {
addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
database.prepare(`
UPDATE accounts
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
}
function sqliteDateFromMs(value) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return new Date().toISOString()
}
return new Date(timestamp).toISOString()
}
function epochMsFromSqliteDate(value) {
const timestamp = Date.parse(String(value ?? ''))
return Number.isFinite(timestamp) ? timestamp : 0
}
function sendJson(response, status, body, headers = {}) {
response.statusCode = status
response.setHeader('Content-Type', 'application/json')
@@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
response.end(JSON.stringify(body))
}
function touchAccountSave(database, accountId) {
database.prepare(`
UPDATE accounts
SET last_saved_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(accountId)
}
function sendMutatingResult(response, database, accountId, body) {
touchAccountSave(database, accountId)
sendJson(response, 200, body)
}
function configuredCorsOrigins() {
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
.split(',')
@@ -1027,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
const account = database.prepare(`
SELECT
completed_dungeon_parts AS completedDungeonParts,
completed_raid_phases AS completedRaidPhases
completed_raid_phases AS completedRaidPhases,
last_saved_at AS lastSavedAt
FROM accounts
WHERE id = ?
`).get(accountId)
@@ -1058,6 +1101,7 @@ function buildSyncSave(database, accountId, activeCharacterId) {
`).get(activeCharacterId)
return {
version: 4,
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
characterName,
activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0,
@@ -1164,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
database.prepare(`
UPDATE accounts
SET completed_dungeon_parts = ?, completed_raid_phases = ?
SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
WHERE id = ?
`).run(
clampInteger(save.completedDungeonParts, 0, 0, 3),
clampInteger(save.completedRaidPhases, 0, 0, 3),
sqliteDateFromMs(save.updatedAt),
accountId,
)
@@ -3004,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try {
const ip = requestIp(request)
@@ -3060,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON')
ensureRuntimeMigrations(database)
try {
const ip = requestIp(request)
@@ -3098,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/profile' && request.method === 'PUT') {
const payload = await readJson(request)
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
sendJson(response, 200, getProfile(database, newCharacterId, session.accountId))
sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
return
}
@@ -3148,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
if (dungeonCompletion && request.method === 'POST') {
const payload = await readJson(request)
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
completeDungeon(
database,
session.characterId,
@@ -3165,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
const payload = await readJson(request)
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
completeRoguelike(database, session.characterId, session.accountId, payload),
)
return
@@ -3175,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
if (talentAllocation && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
)
return
}
if (request.url === '/api/talents/reset' && request.method === 'POST') {
sendJson(response, 200, resetTalents(database, session.characterId))
sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
return
}
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
if (itemEquip && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
equipItem(database, session.characterId, Number(itemEquip[1])),
)
return
@@ -3200,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
if (itemDiscard && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
)
return
@@ -3210,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
if (itemBreakdown && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
)
return
@@ -3220,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
if (recipeCraft && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
craftItem(database, session.characterId, Number(recipeCraft[1])),
)
return
@@ -3230,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
if (itemUpgrade && request.method === 'POST') {
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
)
return
@@ -3241,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
if (encounterLootRoll && request.method === 'POST') {
const payload = await readJson(request)
sendJson(
sendMutatingResult(
response,
200,
database,
session.accountId,
rollEncounterLoot(
database,
session.characterId,
+1543 -30
View File
File diff suppressed because it is too large Load Diff
+70 -1172
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()
+263 -20
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
saveProfile,
type CharacterProfile,
type GameClass,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
@@ -14,20 +15,121 @@ type Props = {
onSaved: (profile: CharacterProfile) => void
}
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
type CustomizeNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
| { kind: 'class'; key: string; row: number; column: number; classId: number }
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
| { kind: 'clear'; key: string; row: number; column: number }
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
| { kind: 'save'; key: string; row: number; column: number }
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
]
const CLASS_PICKER_COLUMN = 0
const CLASS_CONTENT_COLUMN = 2
const ABILITY_LIBRARY_COLUMNS = 5
const ABILITY_LIBRARY_PAGE_SIZE = 10
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
const [abilityPage, setAbilityPage] = useState(0)
const [message, setMessage] = useState('')
const [saving, setSaving] = useState(false)
const scrollRef = useRef<number>(0)
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
const abilityMap = useMemo(
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
[gameClass],
)
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
const visibleAbilities = useMemo(
() => gameClass.spells.slice(
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
),
[currentAbilityPage, gameClass.spells],
)
const classNavActive = activeTab === 'class'
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
const entries: CustomizeNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...CUSTOMIZE_TABS.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab.key}`,
row: 0,
column: index + 1,
tab: tab.key,
})),
]
if (activeTab !== 'class') return entries
entries.push(
...profile.classes.map((candidate, index) => ({
kind: 'class' as const,
key: `class:${candidate.id}`,
row: index + 1,
column: CLASS_PICKER_COLUMN,
classId: candidate.id,
})),
...slots.map((_, index) => ({
kind: 'slot' as const,
key: `slot:${index}`,
row: 1,
column: CLASS_CONTENT_COLUMN + index,
slotIndex: index,
})),
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
...(abilityPageCount > 1 && currentAbilityPage > 0
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
: []),
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
: []),
...visibleAbilities
.filter((ability) => ability.unlockLevel <= profile.character.level)
.map((ability, index) => ({
kind: 'ability' as const,
key: `ability:${ability.id}`,
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
abilityId: ability.id,
})),
)
if (!saving) {
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
}
return entries
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
?? navEntries[0]
function selected(entryKey: string) {
return classNavActive && activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
useEffect(() => {
window.scrollTo(0, scrollRef.current)
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
setSelectedSlot(0)
setAbilityPage(0)
setMessage('')
}
function selectTab(tab: CustomizeTab) {
setActiveTab(tab)
setSelectedNavKey(`tab:${tab}`)
}
function equipAbility(abilityId: number) {
if (slots.includes(abilityId)) {
setMessage('That ability is already equipped.')
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== activeIndex)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < activeEntry.column
if (action === 'navigateRight') return entry.column > activeEntry.column
if (action === 'navigateUp') return entry.row < activeEntry.row
return entry.row > activeEntry.row
})
if (candidates.length === 0) return
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - activeEntry.row)
: Math.abs(a.entry.column - activeEntry.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - activeEntry.row)
: Math.abs(b.entry.column - activeEntry.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
}
function openEntry(entry: CustomizeNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'class') {
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
if (nextClass) chooseClass(nextClass)
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
else if (entry.kind === 'clear') clearSlot()
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
else if (entry.kind === 'save') void persistChanges()
}
useEffect(() => {
if (!classNavActive) return
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry, classNavActive])
useGameAction((action, device) => {
if (device !== 'controller' || !classNavActive) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
}
return (
<section className="content-screen customize-screen">
<section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
<div className="screen-heading customize-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
<button
className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedNavKey('back')}
type="button"
>
Back
</button>
{CUSTOMIZE_TABS.map((tab) => (
<button
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? 'active' : ''}
className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
data-controller-nav={classNavActive ? 'skip' : undefined}
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
onClick={() => selectTab(tab.key)}
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
ref={navRef(`tab:${tab.key}`)}
role="tab"
type="button"
>
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Healing Class</p>
{profile.classes.map((candidate) => (
<button
className={candidate.id === classId ? 'active' : ''}
className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
key={candidate.id}
onClick={() => chooseClass(candidate)}
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
ref={navRef(`class:${candidate.id}`)}
style={{ '--class-color': candidate.themeColor } as CSSProperties}
type="button"
>
<span>{candidate.name[0]}</span>
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const ability = abilityId ? abilityMap.get(abilityId) : undefined
return (
<button
className={selectedSlot === index ? 'selected' : ''}
className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
key={index}
onClick={() => setSelectedSlot(index)}
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
ref={navRef(`slot:${index}`)}
type="button"
>
<kbd>{index + 1}</kbd>
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<p className="eyebrow">Class Abilities</p>
<h2>Ability Library</h2>
</div>
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
<div className="ability-library-actions">
{abilityPageCount > 1 && (
<div className="ability-library-pager">
<button
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
disabled={currentAbilityPage === 0}
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
ref={navRef('ability-page-prev')}
type="button"
>
Prev
</button>
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
<button
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
disabled={currentAbilityPage >= abilityPageCount - 1}
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
onPointerDown={() => setSelectedNavKey('ability-page-next')}
ref={navRef('ability-page-next')}
type="button"
>
Next
</button>
</div>
)}
<button
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('clear-slot') ? 'true' : undefined}
onClick={clearSlot}
onPointerDown={() => setSelectedNavKey('clear-slot')}
ref={navRef('clear-slot')}
type="button"
>
Clear Selected Slot
</button>
</div>
</div>
<div className="ability-library">
{gameClass.spells.map((ability) => {
{visibleAbilities.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return (
<button
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
disabled={locked}
key={ability.id}
onClick={() => equipAbility(ability.id)}
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
ref={navRef(`ability:${ability.id}`)}
type="button"
>
<span>{locked ? 'L' : ability.glyph}</span>
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
<div className="save-row">
<span>{message}</span>
<button
className="primary-button"
className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('save') ? 'true' : undefined}
disabled={saving}
onClick={persistChanges}
onPointerDown={() => setSelectedNavKey('save')}
ref={navRef('save')}
type="button"
>
{saving ? 'Saving...' : 'Save Character'}
+202 -34
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { useGameAction, type InputAction } from '../input'
type HunterProfileScreenProps = {
profile: CharacterProfile
@@ -24,6 +25,17 @@ type CollectionItem = {
source: string
}
type HunterProfileTab = 'stats' | 'collection'
type HunterProfileNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
| { kind: 'stat'; key: string; row: number; column: number; index: number }
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
const HUNTER_PROFILE_DROP_COLUMNS = 6
function bossInitials(name: string) {
return name
.split(/\s+/)
@@ -45,7 +57,9 @@ function purplePetKey(encounterId: number) {
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
const [selectedIndex, setSelectedIndex] = useState(1)
const screenRef = useRef<HTMLElement | null>(null)
const { enabled: dualScreenEnabled } = useDualScreen()
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
dungeon.encounters
@@ -60,8 +74,8 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
const bossKills = profile.hunterStats?.bossKills ?? {}
const bossPets = profile.hunterStats?.bossPets ?? {}
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
const inventoryQuantities = useMemo(
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
[profile.inventory],
@@ -117,7 +131,71 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
},
]
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
const statTiles = useMemo(() => [
{
key: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
key: 'stat:most-killed',
label: 'Most Killed Boss',
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
},
{
key: 'stat:pvp-matches',
label: 'PvP Matches',
value: matchesPlayed,
},
{
key: 'stat:pvp-wins',
label: 'PvP Wins',
value: matchesWon,
},
{
key: 'stat:pvp-win-rate',
label: 'PvP Win Rate',
value: `${winRate}%`,
},
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
const entries: HunterProfileNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
]
if (activeTab === 'stats') {
entries.push(...statTiles.map((tile, index) => ({
kind: 'stat' as const,
key: tile.key,
row: 1,
column: index,
index,
})))
} else {
entries.push(
...bosses.map((boss, index) => ({
kind: 'boss' as const,
key: `boss:${boss.encounter.id}`,
row: index + 1,
column: 0,
bossId: boss.encounter.id,
})),
...collectionItems.map((item, index) => ({
kind: 'item' as const,
key: `item:${item.key}`,
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
itemKey: item.key,
})),
)
}
return entries
}, [activeTab, bosses, collectionItems, statTiles])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
return {
@@ -143,13 +221,94 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
}, [activeTab, bossKills, focusedItem, selectedBoss])
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: HunterProfileTab) {
setActiveTab(tab)
setSelectedIndex(tab === 'stats' ? 1 : 2)
}
function selectBoss(bossId: number) {
setSelectedBossId(bossId)
setFocusedItemKey(null)
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: HunterProfileNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'boss') selectBoss(entry.bossId)
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
}
useEffect(() => {
if (!activeEntry) return
screenRef.current
?.querySelector<HTMLElement>('[data-game-selected="true"]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
return (
<section className="content-screen hunter-profile-screen">
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
<div className="screen-heading hunter-profile-heading">
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
<button
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:stats') ? 'true' : undefined}
onClick={() => selectTab('stats')}
onPointerDown={() => selectKey('tab:stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
@@ -157,8 +316,11 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('tab:collection') ? 'true' : undefined}
onClick={() => selectTab('collection')}
onPointerDown={() => selectKey('tab:collection')}
type="button"
role="tab"
aria-selected={activeTab === 'collection'}
@@ -166,32 +328,32 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
Collection Log
</button>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
type="button"
>
Back
</button>
</div>
{activeTab === 'stats' && (
<div className="hunter-stat-grid">
<article className="hunter-stat-tile">
<span>Total Boss Kills</span>
<strong>{totalBossKills}</strong>
</article>
<article className="hunter-stat-tile">
<span>Most Killed Boss</span>
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
</article>
<article className="hunter-stat-tile">
<span>PvP Matches</span>
<strong>{matchesPlayed}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Wins</span>
<strong>{matchesWon}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Win Rate</span>
<strong>{winRate}%</strong>
</article>
{statTiles.map((tile) => (
<article
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
data-game-selected={selected(tile.key) ? 'true' : undefined}
key={tile.key}
onPointerDown={() => selectKey(tile.key)}
>
<span>{tile.label}</span>
<strong>{tile.value}</strong>
{tile.detail && <small>{tile.detail}</small>}
</article>
))}
</div>
)}
@@ -201,9 +363,12 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
key={boss.encounter.id}
onClick={() => setSelectedBossId(boss.encounter.id)}
onClick={() => selectBoss(boss.encounter.id)}
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
@@ -218,10 +383,13 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
key={item.key}
onClick={() => setFocusedItemKey(item.key)}
onFocus={() => setFocusedItemKey(item.key)}
onPointerDown={() => selectKey(`item:${item.key}`)}
type="button"
>
<span>{item.glyph}</span>
+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 (
+183 -9
View File
@@ -152,6 +152,14 @@ type LivePvpMatch = {
opponentClassName: string
}
type PvpOverlayNavEntry =
| { kind: 'queueBack'; row: number; column: number }
| { kind: 'pauseResume'; row: number; column: number }
| { kind: 'pauseLeave'; row: number; column: number }
| { kind: 'upgradeBuff'; index: number; row: number; column: number }
| { kind: 'upgradeDebuff'; index: number; row: number; column: number }
| { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
@@ -356,6 +364,7 @@ export function PvPRoguelikeScreen({
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
@@ -695,6 +704,7 @@ export function PvPRoguelikeScreen({
setStartStage(setup.startStage)
setStage(setup.startStage)
setStatus('queueing')
setOverlaySelectedIndex(0)
setPlayerSide(setup.playerSide)
setCpuSide(setup.opponentSide)
setSelectedTargetId(partyTemplate[0].id)
@@ -1025,6 +1035,7 @@ export function PvPRoguelikeScreen({
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
setSelectedBuff(null)
setSelectedDebuff(null)
setOverlaySelectedIndex(0)
setStatus('upgrade-choice')
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
@@ -1312,13 +1323,140 @@ export function PvPRoguelikeScreen({
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
function pvpOverlayEntries(): PvpOverlayNavEntry[] {
if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
if (paused) {
return [
{ kind: 'pauseResume', row: 0, column: 0 },
{ kind: 'pauseLeave', row: 1, column: 0 },
]
}
if (status !== 'upgrade-choice') return []
const entries: PvpOverlayNavEntry[] = [
...playerBuffChoices.map((_, index) => ({
kind: 'upgradeBuff' as const,
index,
row: index,
column: 0,
})),
...playerDebuffChoices.map((_, index) => ({
kind: 'upgradeDebuff' as const,
index,
row: index,
column: 1,
})),
]
entries.push({
kind: 'upgradeContinue',
row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
column: 1,
disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
})
return entries
}
function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
const active = entries[bounded]
if (active && !pvpOverlayEntryDisabled(active)) return active
return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
}
function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
const active = activePvpOverlayEntry()
if (!active || active.kind !== kind) return false
if ('index' in active || index !== undefined) return 'index' in active && active.index === index
return true
}
function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
const entries = pvpOverlayEntries()
const nextIndex = entries.findIndex((entry) => {
if (entry.kind !== kind) return false
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
return true
})
if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
}
function movePvpOverlaySelection(action: string) {
const entries = pvpOverlayEntries()
const enabledEntries = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
if (enabledEntries.length === 0) return
setOverlaySelectedIndex((current) => {
const candidate = entries[Math.min(current, entries.length - 1)]
const active = candidate && !pvpOverlayEntryDisabled(candidate)
? candidate
: enabledEntries[0]?.entry
if (!active) return current
const candidates = enabledEntries.filter(({ entry }) => {
if (entry === active) return false
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || a.index - b.index
})
return candidates[0]?.index ?? current
})
}
function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (paused) {
if (action === 'back' || action === 'pause') {
setPaused(false)
return
}
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
return
}
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value)
if (status === 'playing') {
setOverlaySelectedIndex(0)
setPaused((value) => !value)
}
return
}
if (paused || status !== 'playing') return
@@ -1434,9 +1572,18 @@ export function PvPRoguelikeScreen({
>
<section className="content-screen pvp-match-screen">
{status === 'queueing' && (
<div className="placeholder-panel">
<div className="placeholder-panel pvp-match-queue-panel" data-game-nav-active="true">
<div className="placeholder-runes">P V P</div>
<p>{queueMessage}</p>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('queueBack')}
type="button"
>
Back
</button>
</div>
)}
@@ -1552,7 +1699,7 @@ export function PvPRoguelikeScreen({
)}
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="result-screen" data-game-nav-active="true">
<div className="pvp-upgrade-dialog">
<div className="pvp-upgrade-header">
<div>
@@ -1567,9 +1714,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid">
{playerBuffChoices.map((choice) => (
<button
className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedBuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
@@ -1583,9 +1732,11 @@ export function PvPRoguelikeScreen({
<div className="upgrade-choice-grid">
{playerDebuffChoices.map((choice) => (
<button
className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
className={`${selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
data-controller-nav="skip"
key={choice.id}
onClick={() => setSelectedDebuff(choice)}
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
type="button"
>
<strong>{choice.name}</strong>
@@ -1596,7 +1747,14 @@ export function PvPRoguelikeScreen({
</div>
</div>
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
onClick={() => confirmUpgradeChoices()}
onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
type="button"
>
{liveUpgradePending ? 'Waiting' : 'Continue'}
</button>
</div>
@@ -1604,12 +1762,28 @@ export function PvPRoguelikeScreen({
)}
{paused && (
<div className="pause-screen">
<div className="pause-screen" data-game-nav-active="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
<button
className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
data-controller-nav="skip"
onClick={() => setPaused(false)}
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
</button>
</div>
</div>
)}
+15 -16
View File
@@ -944,24 +944,23 @@ export function PvpStadiumScreen({
const finishShop = useCallback(() => {
if (shopReady || status !== 'shop') return
setShopReady(true)
submittedShopRef.current = true
setPlayerSide((current) => {
const next = { ...current, shopReady: true }
playerRef.current = next
return next
})
if (liveMatchRef.current) {
submitPvpUpgradeChoice(liveMatchRef.current.id, {
encounterIndex: roundIndex,
buffId: 'stadium-shop',
debuffId: '',
purchases: playerRef.current.buffs,
shopReady: true,
}).catch(() => undefined)
const liveMatch = liveMatchRef.current
if (!liveMatch) {
startNextRound()
return
}
startNextRound()
setShopReady(true)
submittedShopRef.current = true
const readyPlayer = { ...playerRef.current, shopReady: true }
playerRef.current = readyPlayer
setPlayerSide(readyPlayer)
submitPvpUpgradeChoice(liveMatch.id, {
encounterIndex: roundIndex,
buffId: 'stadium-shop',
debuffId: '',
purchases: playerRef.current.buffs,
shopReady: true,
}).catch(() => undefined)
}, [roundIndex, shopReady, startNextRound, status])
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
+269 -41
View File
@@ -1,20 +1,16 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ACTION_LABELS,
INPUT_ACTIONS,
useInput,
useGameAction,
type InputAction,
type InputDevice,
} from '../input'
import {
ControllerBindingLabel,
ControllerStylePreview,
} from './ControllerIcons'
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
import { useDualScreen } from '../dualScreen'
import {
getNativeDisplays,
@@ -22,11 +18,50 @@ import {
type AndroidDisplay,
} from '../nativeDualScreen'
type SettingsTab = 'display' | 'input' | 'bindings'
type SettingsNavEntry =
| { kind: 'back'; key: string; row: number; column: number }
| { kind: 'tab'; key: string; row: number; column: number; tab: SettingsTab }
| { kind: 'dualToggle'; key: string; row: number; column: number }
| { kind: 'openCompanion'; key: string; row: number; column: number }
| { kind: 'directTargeting'; key: string; row: number; column: number }
| { kind: 'touchLock'; key: string; row: number; column: number }
| { kind: 'iconStyle'; key: string; row: number; column: number; style: keyof typeof CONTROLLER_STYLE_LABELS }
| { kind: 'device'; key: string; row: number; column: number; device: InputDevice }
| { kind: 'binding'; key: string; row: number; column: number; action: InputAction }
| { kind: 'resetBindings'; key: string; row: number; column: number }
const CONTROLLER_STYLE_LABELS = {
xbox: 'Xbox',
playstation: 'PlayStation',
nintendo: 'Nintendo',
} as const
const SETTINGS_TAB_ORDER = ['display', 'input', 'bindings'] as const
const SETTINGS_TAB_LABELS: Record<SettingsTab, string> = {
display: 'Display',
input: 'Input',
bindings: 'Bindings',
}
const SETTINGS_BINDING_COLUMNS = 2
const DIRECT_TARGET_ACTIONS = new Set<InputAction>([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
const [settingsTab, setSettingsTab] = useState<SettingsTab>('display')
const [selectedIndex, setSelectedIndex] = useState(1)
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const navRefs = useRef<Record<string, HTMLElement | null>>({})
const {
bindings,
capture,
@@ -47,20 +82,141 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
openTopDisplay,
} = useDualScreen()
const nativeDualScreen = hasNativeDualScreenBridge()
const directTargetActions = new Set([
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
directPartyTargeting
? action !== 'previousTarget' && action !== 'nextTarget'
: !directTargetActions.has(action)
))
: !DIRECT_TARGET_ACTIONS.has(action)
)), [directPartyTargeting])
const navEntries = useMemo<SettingsNavEntry[]>(() => {
const entries: SettingsNavEntry[] = [
{ kind: 'back', key: 'back', row: 0, column: 0 },
...SETTINGS_TAB_ORDER.map((tab, index) => ({
kind: 'tab' as const,
key: `tab:${tab}`,
row: 0,
column: index + 1,
tab,
})),
]
if (settingsTab === 'display') {
entries.push(
{ kind: 'dualToggle', key: 'display:dual-toggle', row: 1, column: 1 },
{ kind: 'openCompanion', key: 'display:open-companion', row: 1, column: 2 },
)
} else if (settingsTab === 'input') {
entries.push(
{ kind: 'directTargeting', key: 'input:direct-targeting', row: 1, column: 1 },
{ kind: 'touchLock', key: 'input:touch-lock', row: 1, column: 2 },
{ kind: 'iconStyle', key: 'input:icons:xbox', row: 2, column: 0, style: 'xbox' },
{ kind: 'iconStyle', key: 'input:icons:playstation', row: 2, column: 1, style: 'playstation' },
{ kind: 'iconStyle', key: 'input:icons:nintendo', row: 2, column: 2, style: 'nintendo' },
)
} else {
entries.push(
{ kind: 'device', key: 'bindings:device:controller', row: 1, column: 0, device: 'controller' },
{ kind: 'device', key: 'bindings:device:pc', row: 1, column: 1, device: 'pc' },
...visibleActions.map((action, index) => ({
kind: 'binding' as const,
key: `bindings:action:${action}`,
row: Math.floor(index / SETTINGS_BINDING_COLUMNS) + 2,
column: index % SETTINGS_BINDING_COLUMNS,
action,
})),
{
kind: 'resetBindings',
key: 'bindings:reset',
row: Math.floor((visibleActions.length - 1) / SETTINGS_BINDING_COLUMNS) + 3,
column: 1,
},
)
}
return entries
}, [settingsTab, visibleActions])
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
const activeEntry = navEntries[activeIndex]
function selected(entryKey: string) {
return activeEntry?.key === entryKey
}
function navRef(entryKey: string) {
return (node: HTMLElement | null) => {
navRefs.current[entryKey] = node
}
}
function selectKey(entryKey: string) {
const index = navEntries.findIndex((entry) => entry.key === entryKey)
if (index >= 0) setSelectedIndex(index)
}
function selectTab(tab: SettingsTab) {
setSettingsTab(tab)
const tabIndex = SETTINGS_TAB_ORDER.indexOf(tab)
setSelectedIndex(tabIndex + 1)
}
function firstContentKey(tab: SettingsTab) {
if (tab === 'display') return 'display:dual-toggle'
if (tab === 'input') return 'input:direct-targeting'
return 'bindings:device:controller'
}
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
if (action === 'navigateDown' && active.row === 0) {
const nextIndex = navEntries.findIndex((entry) => entry.key === firstContentKey(settingsTab))
if (nextIndex >= 0) return nextIndex
}
if (action === 'navigateUp' && active.row === 1) {
const tabIndex = navEntries.findIndex((entry) => entry.key === `tab:${settingsTab}`)
if (tabIndex >= 0) return tabIndex
}
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
}
function openEntry(entry: SettingsNavEntry | undefined) {
if (!entry) return
if (entry.kind === 'back') onBack()
else if (entry.kind === 'tab') selectTab(entry.tab)
else if (entry.kind === 'dualToggle') {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
} else if (entry.kind === 'openCompanion') {
void launchTopDisplay()
} else if (entry.kind === 'directTargeting') setDirectPartyTargeting(!directPartyTargeting)
else if (entry.kind === 'touchLock') setCombatTouchLocked(!combatTouchLocked)
else if (entry.kind === 'iconStyle') setControllerIconStyle(entry.style)
else if (entry.kind === 'device') setDevice(entry.device)
else if (entry.kind === 'binding') beginCapture(device, entry.action)
else if (entry.kind === 'resetBindings') resetBindings(device)
}
async function refreshNativeDisplays() {
if (!nativeDualScreen) return
@@ -79,6 +235,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
.catch(() => setAndroidDisplays([]))
}, [nativeDualScreen])
useEffect(() => {
if (!activeEntry) return
navRefs.current[activeEntry.key]?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [activeEntry])
useGameAction((action, inputDevice) => {
if (inputDevice !== 'controller' || capture) return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openEntry(activeEntry)
return
}
moveSelection(action)
})
async function launchTopDisplay() {
const opened = await openTopDisplay()
setDisplayMessage(opened
@@ -90,24 +264,34 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
}
return (
<section className="content-screen settings-screen">
<section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
<div className="settings-nav">
<button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
<button
className={`back-button settings-back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectKey('back')}
ref={navRef('back')}
type="button"
>
Back
</button>
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
{([
{ key: 'display', label: 'Display' },
{ key: 'input', label: 'Input' },
{ key: 'bindings', label: 'Bindings' },
] as const).map((tab) => (
{SETTINGS_TAB_ORDER.map((tab) => (
<button
aria-selected={settingsTab === tab.key}
className={settingsTab === tab.key ? 'selected' : ''}
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
aria-selected={settingsTab === tab}
className={`${settingsTab === tab ? 'selected' : ''} ${selected(`tab:${tab}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`tab:${tab}`) ? 'true' : undefined}
key={tab}
onClick={() => selectTab(tab)}
onPointerDown={() => selectKey(`tab:${tab}`)}
ref={navRef(`tab:${tab}`)}
role="tab"
type="button"
>
{tab.label}
{SETTINGS_TAB_LABELS[tab]}
</button>
))}
</nav>
@@ -125,16 +309,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
className={`${dualScreenEnabled ? 'selected' : ''} ${selected('display:dual-toggle') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('display:dual-toggle') ? 'true' : undefined}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
onPointerDown={() => selectKey('display:dual-toggle')}
ref={navRef('display:dual-toggle')}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
<button
className={selected('display:open-companion') ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selected('display:open-companion') ? 'true' : undefined}
onClick={launchTopDisplay}
onPointerDown={() => selectKey('display:open-companion')}
ref={navRef('display:open-companion')}
type="button"
>
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
@@ -171,16 +367,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
className={`${directPartyTargeting ? 'selected' : ''} ${selected('input:direct-targeting') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('input:direct-targeting') ? 'true' : undefined}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
onPointerDown={() => selectKey('input:direct-targeting')}
ref={navRef('input:direct-targeting')}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<button
aria-pressed={combatTouchLocked}
className={combatTouchLocked ? 'selected' : ''}
className={`${combatTouchLocked ? 'selected' : ''} ${selected('input:touch-lock') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('input:touch-lock') ? 'true' : undefined}
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
onPointerDown={() => selectKey('input:touch-lock')}
ref={navRef('input:touch-lock')}
type="button"
>
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
@@ -190,9 +394,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
className={`${controllerIconStyle === style ? 'selected' : ''} ${selected(`input:icons:${style}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`input:icons:${style}`) ? 'true' : undefined}
key={style}
onClick={() => setControllerIconStyle(style)}
onPointerDown={() => selectKey(`input:icons:${style}`)}
ref={navRef(`input:icons:${style}`)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
@@ -215,15 +423,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
className={`${device === 'controller' ? 'selected' : ''} ${selected('bindings:device:controller') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:device:controller') ? 'true' : undefined}
onClick={() => setDevice('controller')}
onPointerDown={() => selectKey('bindings:device:controller')}
ref={navRef('bindings:device:controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
className={`${device === 'pc' ? 'selected' : ''} ${selected('bindings:device:pc') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:device:pc') ? 'true' : undefined}
onClick={() => setDevice('pc')}
onPointerDown={() => selectKey('bindings:device:pc')}
ref={navRef('bindings:device:pc')}
type="button"
>
PC
@@ -233,9 +449,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
className={`${capture?.device === device && capture.action === action ? 'listening' : ''} ${selected(`bindings:action:${action}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`bindings:action:${action}`) ? 'true' : undefined}
key={action}
onClick={() => beginCapture(device, action)}
onPointerDown={() => selectKey(`bindings:action:${action}`)}
ref={navRef(`bindings:action:${action}`)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
@@ -255,7 +475,15 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
<button
className={`text-button ${selected('bindings:reset') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('bindings:reset') ? 'true' : undefined}
onClick={() => resetBindings(device)}
onPointerDown={() => selectKey('bindings:reset')}
ref={navRef('bindings:reset')}
type="button"
>
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
+123 -1
View File
@@ -97,14 +97,34 @@ export type DualScreenWorkshopState = {
}>
}
export type DualScreenSetupState = {
contentType: 'dungeon' | 'raid'
title: string
subtitle: string
description: string
initials: string
difficultyName: string
itemLevel: number
experience: number
lockedReason?: string
stats: {
health: string
damage: string
xp: string
loot: string
}
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'setup-state'; state: DualScreenSetupState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
| { type: 'workshop-ended' }
| { type: 'setup-ended' }
type DualScreenContextValue = {
enabled: boolean
@@ -160,6 +180,14 @@ function shouldRelayBottomAction(
return action === 'pause' || action === 'back'
}
function shouldRelaySetupAction(
action: InputAction,
state: DualScreenSetupState | null,
) {
if (!state) return false
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -361,6 +389,9 @@ export function useDualScreenWorkshopPublisher(
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
@@ -377,9 +408,53 @@ export function useDualScreenWorkshopPublisher(
}, [enabled, state])
}
export function useDualScreenSetupPublisher(
state: DualScreenSetupState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'setup-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
if (event.data.type === 'control-action') {
dispatchExternalGameAction(event.data.action, 'controller')
}
}
publish()
return () => {
channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
const channel = createChannel()
if (!enabled || !channel) return
if (state) channel.postMessage({ type: 'setup-state', state } satisfies DualScreenMessage)
else channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
channel.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
useEffect(() => {
const channel = createChannel()
@@ -389,13 +464,21 @@ export function DualScreenBottomDisplay() {
if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
setSetupState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
setSetupState(null)
}
if (event.data.type === 'setup-state') {
setSetupState(event.data.state)
setState(null)
setWorkshopState(null)
}
if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
if (event.data.type === 'setup-ended') setSetupState(null)
}
announce()
const timer = window.setInterval(() => {
@@ -414,7 +497,10 @@ export function DualScreenBottomDisplay() {
}
useGameAction((action) => {
if (!shouldRelayBottomAction(action, state)) return
if (
!shouldRelayBottomAction(action, state)
&& !shouldRelaySetupAction(action, setupState)
) return
sendAction(action)
})
@@ -452,6 +538,42 @@ export function DualScreenBottomDisplay() {
)
}
if (!state && setupState) {
return (
<main className="dual-bottom-display setup-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{setupState.contentType}</p>
<h1>{setupState.title}</h1>
<small>{setupState.subtitle}</small>
</div>
<div className="dual-controls-progress">
<span>{setupState.difficultyName}</span>
<span>iLvl {setupState.itemLevel}</span>
<span>{setupState.experience} XP</span>
</div>
</header>
<section className="setup-bottom-summary">
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
{setupState.initials}
</span>
<div>
<p className="eyebrow">Selected Run</p>
<h2>{setupState.title}</h2>
<p>{setupState.description}</p>
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
</div>
</section>
<dl className="setup-bottom-stats">
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
</dl>
</main>
)
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
+108 -14
View File
@@ -86,6 +86,7 @@ type CharacterData = {
type OfflineSave = {
version: 4
updatedAt: number
characterName: string
activeClassId: number
completedDungeonParts: number
@@ -117,6 +118,22 @@ export type CloudSyncStatus = {
dirty: boolean
}
export type CloudSaveAge = 'server-newer' | 'local-newer' | 'same' | 'unknown'
export type CloudSaveSummary = {
updatedAt: number | null
characterName: string
activeClassId: number
}
export type CloudSyncComparison = {
relation: CloudSaveAge
local: CloudSaveSummary
server: CloudSaveSummary
}
export type CloudSyncChoice = 'local' | 'server'
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
type NetworkError = Error & {
@@ -144,6 +161,30 @@ function clone<T>(value: T): T {
return structuredClone(value)
}
function normalizedSaveTimestamp(value: unknown): number {
const timestamp = Number(value)
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0
}
function stampSave(save: OfflineSave, updatedAt = Date.now()): OfflineSave {
save.updatedAt = updatedAt
return save
}
function saveSummary(save: OfflineSave): CloudSaveSummary {
return {
updatedAt: save.updatedAt > 0 ? save.updatedAt : null,
characterName: save.characterName,
activeClassId: save.activeClassId,
}
}
function compareSaveAge(local: OfflineSave, server: OfflineSave): CloudSaveAge {
if (local.updatedAt <= 0 || server.updatedAt <= 0) return 'unknown'
if (Math.abs(local.updatedAt - server.updatedAt) < 1000) return 'same'
return server.updatedAt > local.updatedAt ? 'server-newer' : 'local-newer'
}
function toGameMode(mode: RepositoryMode): GameMode {
return mode === 'online' ? 'online' : 'offline'
}
@@ -190,6 +231,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
}
return {
version: 4,
updatedAt: 0,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
@@ -210,6 +252,7 @@ function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' |
return normalizeSaveAbilitySlots({
...v2,
version: 4,
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
completedRaidPhases: 0,
bossKills: {},
bossPets: {},
@@ -222,6 +265,7 @@ function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets
return normalizeSaveAbilitySlots({
...v3,
version: 4,
updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
@@ -244,6 +288,7 @@ function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
}
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
for (const character of Object.values(save.characters)) {
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
}
@@ -590,6 +635,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
}
return {
version: 4,
updatedAt: existingSave?.updatedAt ?? Date.now(),
characterName: profile.character.name,
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
@@ -1008,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave {
return save
}
function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
store.writeSave(stampSave(save))
}
function createLocalRepository(store: LocalSaveStore): GameRepository {
return {
async loadSession() {
@@ -1055,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
@@ -1161,7 +1211,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
}
store.writeSave(save)
writeStoreSave(store, save)
const updatedProfile = buildProfile(save)
return {
@@ -1284,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
)
cd.inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
const updatedProfile = buildProfile(save)
return {
@@ -1339,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
cd.talentRanks[String(talentId)] = 1
}
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
}
if (cd.talentPoints <= 0) {
@@ -1367,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async resetTalents() {
@@ -1390,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
cd.talentPoints + refunded,
)
}
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async equipItem(itemId) {
@@ -1402,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
@@ -1413,7 +1463,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async breakdownItem(itemId) {
@@ -1456,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async craftItem(recipeId) {
@@ -1484,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async upgradeItem(itemId) {
@@ -1527,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
if (upgraded) upgraded.equipped = true
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
@@ -1610,7 +1660,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
writeStoreSave(store, save)
return clone(result)
},
async recordBossKill(encounterId, options) {
@@ -1623,7 +1673,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const petAwarded = recordBossKillInSave(save, encounter, {
petVariant: options?.petVariant ?? 'normal',
})
store.writeSave(save)
writeStoreSave(store, save)
return {
profile: buildProfile(save),
petAwarded,
@@ -1633,7 +1683,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const save = requireStoredSave(store)
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
store.writeSave(save)
writeStoreSave(store, save)
return buildProfile(save)
},
}
@@ -1750,6 +1800,49 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
return buildProfile(synced.save)
}
export async function previewCloudSaveSync(): Promise<CloudSyncComparison> {
const cache = readOnlineCache()
if (!cache) {
throw new Error('No signed-in save is available for cloud sync.')
}
await refreshCatalogFromServer()
const serverSave = await loadServerSyncSave()
return {
relation: compareSaveAge(cache.save, serverSave),
local: saveSummary(cache.save),
server: saveSummary(serverSave),
}
}
export async function applyCloudSaveSync(choice: CloudSyncChoice): Promise<CharacterProfile> {
const cache = readOnlineCache()
if (!cache) {
throw new Error('No signed-in save is available for cloud sync.')
}
await refreshCatalogFromServer()
if (choice === 'server') {
const serverSave = await loadServerSyncSave()
writeOnlineCache({
version: 1,
account: cache.account,
save: serverSave,
dirty: false,
})
writeMode('online')
return buildProfile(serverSave)
}
const synced = await pushServerSyncSave(cache.save)
await refreshCatalogFromServer()
writeOnlineCache({
version: 1,
account: cache.account,
save: synced.save,
dirty: false,
})
writeMode('online')
return buildProfile(synced.save)
}
export function selectOnlineMode() {
writeMode('online')
}
@@ -1765,6 +1858,7 @@ export function createOfflineCharacter(characterName: string): AuthSession {
}
const save: OfflineSave = {
version: 4,
updatedAt: Date.now(),
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
+88 -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(),
}
@@ -349,6 +353,7 @@ function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
}
export function focusFirstControl() {
if (hasDedicatedGameNavigation()) return null
const first = focusableElements()[0]
if (first) focusControl(first)
return first
@@ -529,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
}
@@ -611,6 +620,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device)
return
}
@@ -684,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',
@@ -810,6 +826,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
if (hasDedicatedGameNavigation()) return
const candidates = focusableElements()
const activeControl = currentFocusableControl(candidates)
if (
@@ -1043,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
}