Compare commits

...
11 Commits
Author SHA1 Message Date
Warren H 547044892d Android build v1.1.21 2026-07-04 21:15:29 -04:00
Warren H 81d65bc381 Android build v1.1.20 2026-07-04 21:15:04 -04:00
Warren H bdae0007a1 Android build v1.1.19 2026-07-04 20:50:33 -04:00
Warren H c052b086f8 Android build v1.1.18 2026-07-04 19:31:58 -04:00
Warren H c2a14bac7d Android build v1.1.17 2026-07-04 18:07:50 -04:00
Warren H 0d269a6041 Android build v1.1.16 2026-07-04 17:41:45 -04:00
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
108 changed files with 13342 additions and 1318 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 100
versionName "1.1.21"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
}
Set<String> currentTokens = new HashSet<>();
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_HAT_X),
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_X),
leftStickX,
"Axis0-",
"Axis0+"
);
addAxisTokens(
currentTokens,
event.getAxisValue(MotionEvent.AXIS_Y),
leftStickY,
"Axis1-",
"Axis1+"
);
@@ -240,6 +243,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
);
}
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(
() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
}
);
}
private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
+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,
+2004 -35
View File
File diff suppressed because it is too large Load Diff
+80 -1114
View File
File diff suppressed because it is too large Load Diff
+357 -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,133 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
}
}
function applyIwt2AdminOverrides(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
overrides: Iwt2BalanceOverrides,
): Record<Iwt2BossId, Iwt2BossMetadata> {
return (Object.keys(metadata) as Iwt2BossId[]).reduce((nextMetadata, bossId) => ({
...nextMetadata,
[bossId]: {
...metadata[bossId],
...overrides.bosses?.[bossId],
},
}), {} as Record<Iwt2BossId, Iwt2BossMetadata>)
}
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>
+170 -1
View File
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
opponentClassName?: string
opponentParty?: PartyMember[]
opponentEnemyHealth?: number
opponentArena?: {
bounds: { width: number, height: number }
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
}
opponentResource?: number
opponentMaxResource?: number
opponentResourceName?: string
@@ -97,14 +102,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 +185,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 +394,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 +413,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 +469,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 +502,10 @@ export function DualScreenBottomDisplay() {
}
useGameAction((action) => {
if (!shouldRelayBottomAction(action, state)) return
if (
!shouldRelayBottomAction(action, state)
&& !shouldRelaySetupAction(action, setupState)
) return
sendAction(action)
})
@@ -452,6 +543,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">
@@ -516,6 +643,48 @@ export function DualScreenBottomDisplay() {
</section>
)}
{state.opponentArena && (
<section className="dual-opponent-arena" aria-label="Opponent arena">
<div
className="dual-opponent-arena-field"
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
>
{state.opponentArena.bosses.map((boss) => (
<div
className="dual-opponent-arena-entity boss"
key={boss.id}
style={{
backgroundColor: boss.color,
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
>
{boss.icon}
</div>
))}
{state.opponentArena.party.map((member) => (
<div
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
key={member.id}
style={{
backgroundColor: member.color,
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
opacity: member.health > 0 ? 1 : 0.35,
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
}}
>
{member.icon}
</div>
))}
</div>
</section>
)}
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
{state.opponentParty.map((member) => (
<PartyMemberFrame
+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,
+108 -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',
@@ -130,14 +134,16 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
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 NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
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
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85
const NATIVE_STICK_TIMEOUT_MS = 140
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
@@ -235,13 +241,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 +355,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 +536,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 +622,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 +696,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 +828,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 +1062,92 @@ 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>()
let nativeStick: MovementVector = { x: 0, y: 0 }
let nativeStickUpdatedAt = 0
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 native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
? applyStickDeadzone(nativeStick.x, nativeStick.y)
: { x: 0, y: 0 }
const keyboard = keyboardVector()
movementRef.current = Math.hypot(native.x, native.y) > 0
? native
: Math.hypot(stick.x, stick.y) > 0
? stick
: keyboard
frame = window.requestAnimationFrame(updateMovement)
}
const onNativeMotion = (event: Event) => {
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
nativeStick = {
x: Number(detail.x) || 0,
y: Number(detail.y) || 0,
}
nativeStickUpdatedAt = performance.now()
document.documentElement.dataset.inputDevice = 'controller'
}
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(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
frame = window.requestAnimationFrame(updateMovement)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
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
+642
View File
@@ -0,0 +1,642 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useGameAction } from '../../input'
import { getGameMode } from '../../gameRepository'
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
import {
useDualScreen,
useDualScreenSetupPublisher,
useDualScreenWorkshopPublisher,
type DualScreenSetupState,
type DualScreenWorkshopState,
} from '../../dualScreen'
import { BossArenaScreen } from './screens/BossArenaScreen'
import {
Iwt2CloudSaveScreen,
Iwt2CustomizeCharacterScreen,
Iwt2DungeonsScreen,
Iwt2HunterProfileScreen,
Iwt2ModeScreen,
Iwt2RoguelikeScreen,
Iwt2RoguelikeUpgradeScreen,
Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens'
import {
loadIwt2Save,
writeIwt2Save,
type Iwt2Save,
} from './save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
import {
buildIwt2OpponentDebuffChoices,
buildIwt2SelfBuffChoices,
IWT2_REVIVE_PARTY_CHOICE,
type Iwt2RoguelikeChoice,
type Iwt2RoguelikeContentType,
type Iwt2RoguelikeOpponentDebuffId,
type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant,
} from './content/roguelike'
type Iwt2Screen =
| 'menu'
| 'arena'
| 'cloud-save'
| 'dungeons'
| 'raids'
| 'roguelike'
| 'roguelike-arena'
| 'roguelike-upgrade'
| 'hunter-profile'
| 'customize-character'
| 'settings'
const IWT2_MENU_COLUMNS = 4
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[]
buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[]
debuffChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
stage: number
variant: Iwt2RoguelikeVariant
}
const MENU_ITEMS: Array<{
screen: Iwt2Screen
title: string
description: string
glyph: string
}> = [
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Save or restore the isolated IWT2 progress slot.',
glyph: 'C',
},
{
screen: 'dungeons',
title: 'Dungeons',
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
glyph: 'D',
},
{
screen: 'raids',
title: 'Raids',
description: 'Open raid assignments built from active IWT2 boss mechanics.',
glyph: 'R',
},
{
screen: 'roguelike',
title: 'Roguelike',
description: 'Draft upgrades through escalating random encounters.',
glyph: 'L',
},
{
screen: 'roguelike',
title: 'PvP',
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
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: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
glyph: 'K',
},
{
screen: 'settings',
title: 'Settings',
description: 'IWT2 targeting preference and controller icon style.',
glyph: 'S',
},
]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
writeIwt2Save(save)
}, [save])
useEffect(() => () => {
cancelPvpQueueRef.current?.()
}, [])
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => buildIwt2WorkshopDualScreenState(screen, save),
[save, screen],
)
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
onBackToGameSelect()
return
}
if (action === 'confirm') {
openMenuItem(MENU_ITEMS[selectedIndex])
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))
}
})
function openMenuItem(item: (typeof MENU_ITEMS)[number]) {
if (item.title === 'PvP') {
setRoguelikeVariant('pvp')
setScreen('roguelike')
return
}
if (item.title === 'Roguelike') {
setRoguelikeVariant('pve')
setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current)
}
setScreen(item.screen)
}
if (screen === 'arena') {
return (
<BossArenaScreen
bossId={selectedBossId}
key={`arena-${selectedBossId}-${arenaModeLabel}`}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
)
}
if (screen === 'roguelike-arena' && roguelikeRun) {
return (
<BossArenaScreen
bossIds={roguelikeRun.bossIds}
bossId={selectedBossId}
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
roguelikeRun={{
buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType,
debuffs: roguelikeRun.debuffs,
onVictory: () => {
setRoguelikeRun((current) => current
? {
...current,
...buildRoguelikeChoices(save, current.variant),
}
: current)
setScreen('roguelike-upgrade')
},
stage: roguelikeRun.stage,
variant: roguelikeRun.variant,
}}
save={save}
onBack={() => setScreen('roguelike')}
onSaveUpdated={setSave}
/>
)
}
if (screen === 'roguelike-upgrade' && roguelikeRun) {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2RoguelikeUpgradeScreen
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
contentType={roguelikeRun.contentType}
debuffChoices={roguelikeRun.debuffChoices}
selfChoices={roguelikeRun.selfChoices}
stage={roguelikeRun.stage}
variant={roguelikeRun.variant}
onBack={() => setScreen('roguelike')}
onChoose={(buffId, debuffId) => {
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
setRoguelikeRun(nextRun)
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena')
}}
/>
</main>
)
}
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2DungeonsScreen
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Dungeon')
setSelectedBossId(bossId)
setScreen('arena')
}}
/>
</main>
)
}
if (screen === 'roguelike') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
onBack={() => setScreen('menu')}
onContentTypeChange={setRoguelikeContentType}
onStart={() => {
startIwt2RoguelikeRun()
}}
onVariantChange={(nextVariant) => {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
setRoguelikeVariant(nextVariant)
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
setRoguelikeContentType('dungeon')
}
}}
queueMessage={pvpQueueMessage}
/>
</main>
)
}
if (screen === 'raids') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModeScreen
mode="Raids"
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Raid')
setSelectedBossId(bossId)
setScreen('arena')
}}
/>
</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')}
onSaveUpdated={setSave}
/>
</main>
)
}
if (screen === 'cloud-save') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</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"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={`${item.screen}-${item.title}`}
onClick={() => openMenuItem(item)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.title}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</div>
</section>
)}
</main>
)
function startIwt2RoguelikeRun() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
if (roguelikeVariant !== 'pvp') {
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
return
}
const startStage = 1
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
contentType: roguelikeContentType,
startStage,
gameMode: getGameMode(),
liveMatchActive: () => false,
onSearching: setPvpQueueMessage,
onCpuMatch: (_difficulty, message) => {
cancelPvpQueueRef.current = null
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
onLiveMatch: (...liveMatchArgs) => {
const message = liveMatchArgs[2]
cancelPvpQueueRef.current = null
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
messages: {
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
},
})
}
function beginIwt2RoguelikeArena(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
) {
const nextRun = createRoguelikeRun(save, variant, contentType)
setRoguelikeRun(nextRun)
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
setScreen('roguelike-arena')
}
}
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
if (contentType === 'raid') return 'Raid'
if (contentType === 'stadium') return 'Stadium'
return 'Dungeon'
}
function createRoguelikeRun(
save: Iwt2Save,
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
bossIds: createRandomRoguelikeBossPair(),
buffs: [],
contentType,
debuffs: [],
...buildRoguelikeChoices(save, variant),
stage: 1,
variant,
}
}
function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
const abilities = abilitiesForHealer(save.character.healerStyle)
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
return {
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
debuffChoices: variant === 'pvp'
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
: [],
}
}
function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) {
if (buffs.length === 0) return ''
const abilities = abilitiesForHealer(save.character.healerStyle)
return summarizeChoiceStacks(
buffs,
[IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)],
'None',
)
}
function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) {
if (debuffs.length === 0) return ''
const abilities = abilitiesForHealer(save.character.healerStyle)
return summarizeChoiceStacks(
debuffs,
buildIwt2OpponentDebuffChoices(abilities),
'None',
)
}
function applyRoguelikeChoice(
run: Iwt2RoguelikeRunState,
buffId: Iwt2RoguelikeSelfBuffId,
debuffId: Iwt2RoguelikeOpponentDebuffId | undefined,
save: Iwt2Save,
): Iwt2RoguelikeRunState {
const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs
const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id
? {
buffs: run.buffs,
debuffs: nextDebuffs.slice(1),
}
: {
buffs: [...run.buffs, buffId],
debuffs: nextDebuffs,
}
return {
...run,
...nextBase,
bossIds: createRandomRoguelikeBossPair(),
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
}
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
return chooseRunChoices(pool, 2)
}
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
const pool = [...items]
const choices: T[] = []
while (pool.length > 0 && choices.length < count) {
const index = Math.floor(Math.random() * pool.length)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
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>
)
}
function buildIwt2SetupDualScreenState(
screen: Iwt2Screen,
selectedBossId: Iwt2BossId,
save: Iwt2Save,
): DualScreenSetupState | null {
if (screen !== 'dungeons' && screen !== 'raids') return null
const boss = IWT2_BOSS_METADATA[selectedBossId]
const raid = screen === 'raids'
return {
contentType: raid ? 'raid' : 'dungeon',
description: raid
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
difficultyName: `IWT2 Level ${save.character.level}`,
experience: 125,
initials: boss.icon,
itemLevel: save.character.level,
stats: {
damage: `${boss.meleeDamage}`,
health: `${boss.maxHealth}`,
loot: 'IWT2',
xp: '125',
},
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
}
}
function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,
): DualScreenWorkshopState | null {
if (screen === 'hunter-profile') {
return {
items: [
{
glyph: 'H',
meta: `${save.character.experience} XP`,
status: `Level ${save.character.level}`,
title: save.character.name,
},
{
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
status: 'Class',
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
},
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
glyph: IWT2_BOSS_METADATA[bossId].icon,
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
title: IWT2_BOSS_METADATA[bossId].name,
})),
],
mode: 'collection',
subtitle: 'IWT2 inventory and collection log',
summary: `${save.inventory.length} inventory slots`,
title: 'Hunter Profile',
}
}
if (screen === 'customize-character') {
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
return {
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
glyph: ability.icon,
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
status: `Slot ${ability.slot}`,
title: ability.name,
})),
mode: 'class',
subtitle: healer.description,
summary: healer.name,
title: 'Customize Character',
}
}
return null
}
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
if (bossId === 'khezu') return 'khezu-pearl'
return 'raw-bulldrome-coin'
}
+81
View File
@@ -0,0 +1,81 @@
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, IWT2_HEALER_METADATA } from './content/healerAbilities'
import { loadIwt2Save } from './save/iwt2Repository'
export function Iwt2BottomDisplay() {
const {
bindings,
directPartyTargeting,
lastDevice,
} = useInput()
const activeBindings = lastDevice === 'controller'
? bindings.controller
: DEFAULT_BINDINGS.controller
const save = loadIwt2Save()
const abilities = abilitiesForHealer(save.character.healerStyle)
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
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>{healer.name}</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 (
<div className="dual-control-chip" key={target.id}>
<ControllerBindingLabel
binding={activeBindings[action]}
iconStyle="playstation"
/>{' '}
{label}
</div>
)
})
) : (
<>
<div className="dual-control-chip">
<ControllerBindingLabel binding="Button12" iconStyle="playstation" /> Previous Target
</div>
<div className="dual-control-chip">
Next Target <ControllerBindingLabel binding="Button13" iconStyle="playstation" />
</div>
</>
)}
</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 (
<div className="spell iwt2-bottom-spell" key={ability.id}>
<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>
</div>
)
})}
</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>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { Iwt2BossEntityState } from '../sim'
import { ArenaBar } from './ArenaBars'
import { IWT2_BOSS_METADATA } from '../content/bosses'
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
const entries = bosses ?? (boss ? [boss] : [])
return (
<div className="iwt2-boss-hud">
{entries.map((entry) => {
const metadata = IWT2_BOSS_METADATA[entry.bossId]
return (
<div className="iwt2-boss-hud-row" key={entry.id}>
<div>
<strong>{metadata.name}</strong>
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
</div>
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
</div>
)
})}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
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"
data-game-selected={selected ? 'true' : undefined}
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: {},
}
+226
View File
@@ -0,0 +1,226 @@
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
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
packHowlCooldown?: number
packHowlWindup?: number
packLaneDamage?: number
packLaneStunSeconds?: number
packLaneWidth?: number
packLaneCount?: number
thunderRingCooldown?: number
thunderRingWindup?: number
thunderRingInnerRadius?: number
thunderRingOuterRadius?: number
thunderRingDamage?: number
thunderRingStunSeconds?: number
lightningStrikeCooldown?: number
lightningStrikeWindup?: number
lightningStrikeRadius?: number
lightningStrikeDamage?: number
lightningStrikeStunSeconds?: number
lightningStrikeCount?: 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,
}
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'great-jaggi',
name: 'Great Jaggi',
icon: 'J',
color: '#3f8f73',
accentColor: '#b8f0aa',
maxHealth: 620,
radius: 27,
moveSpeed: 146,
meleeRange: 52,
meleeDamage: 7,
meleeCooldown: 0.82,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
packHowlCooldown: 5.8,
packHowlWindup: 0.95,
packLaneDamage: 15,
packLaneStunSeconds: 0.45,
packLaneWidth: 24,
packLaneCount: 3,
}
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'khezu',
name: 'Khezu',
icon: 'K',
color: '#d8d7c9',
accentColor: '#77d9ff',
maxHealth: 760,
radius: 30,
moveSpeed: 92,
meleeRange: 58,
meleeDamage: 10,
meleeCooldown: 1.15,
chargeCooldown: 0,
chargeWindup: 0,
chargeSpeed: 0,
chargeDamage: 0,
chargeStunSeconds: 0,
chargeLength: 0,
chargeOvershoot: 0,
chargeWidth: 0,
slamWindup: 0,
slamRadius: 0,
slamDamage: 0,
slamStunSeconds: 0,
thunderRingCooldown: 7.2,
thunderRingWindup: 0.9,
thunderRingInnerRadius: 70,
thunderRingOuterRadius: 172,
thunderRingDamage: 22,
thunderRingStunSeconds: 0.7,
lightningStrikeCooldown: 4.2,
lightningStrikeWindup: 0.78,
lightningStrikeRadius: 46,
lightningStrikeDamage: 17,
lightningStrikeStunSeconds: 0.55,
lightningStrikeCount: 2,
}
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 GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA)
export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_BOSS_METADATA)
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
bulldrome: BULLDROME_BOSS_METADATA,
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
khezu: KHEZU_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',
]
+116
View File
@@ -0,0 +1,116 @@
import type { Spell } from '../../../game'
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
export type Iwt2HealerMetadata = {
id: Iwt2HealerId
name: string
icon: string
description: string
}
export type Iwt2HealerAbility = {
id: string
healerId: Iwt2HealerId
slot: number
name: string
icon: string
kind: Spell['kind']
power: number
effectType?: string
manaCost: number
cooldownSeconds: number
target: Iwt2AbilityTarget
extraTargets?: number
}
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
dawnweaver: {
id: 'dawnweaver',
name: 'Dawnweaver',
icon: '+',
description: 'Direct heals, radiant group recovery, shields, and cleanse.',
},
lifebinder: {
id: 'lifebinder',
name: 'Lifebinder',
icon: '~',
description: 'Verdant healing over time, barkskin shielding, and steady group recovery.',
},
runesage: {
id: 'runesage',
name: 'Runesage',
icon: 'R',
description: 'Rune-scripted mends, concordance healing, aegis shielding, and unraveling cleanse.',
},
}
export const IWT2_HEALER_ORDER: Iwt2HealerId[] = ['dawnweaver', 'lifebinder', 'runesage']
export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> = {
dawnweaver: [
createAbility('dawnweaver', 1, 'mend', 'Mend', '+', 'direct', 30, 5, 0.5),
createAbility('dawnweaver', 2, 'renew', 'Renew', '~', 'hot', 12, 7, 0.5, 'heal_over_time'),
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
createAbility('dawnweaver', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
],
lifebinder: [
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
createAbility('lifebinder', 2, 'seed-of-life', 'Seed of Life', '~', 'hot', 16, 9, 1, 'heal_over_time'),
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'),
createAbility('lifebinder', 5, 'purging-sap', 'Purging Sap', 'x', 'cleanse', 12, 6, 4.5, 'cleanse'),
createAbility('lifebinder', 6, 'ancient-grove', 'Ancient Grove', 'G', 'shield', 72, 18, 12, 'shield'),
],
runesage: [
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
createAbility('runesage', 2, 'mending-rune', 'Mending Rune', '~', 'hot', 10, 5, 0.45, 'heal_over_time'),
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'),
createAbility('runesage', 5, 'unravel', 'Unravel', 'x', 'cleanse', 8, 4, 3.5, 'cleanse'),
createAbility('runesage', 6, 'grand-design', 'Grand Design', 'R', 'group', 22, 13, 8.5),
],
}
export function abilitiesForHealer(healerId: Iwt2HealerId | string) {
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
}
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
if (value === 'field_medic') return 'dawnweaver'
if (value === 'ward_sage') return 'lifebinder'
if (value === 'storm_chanter') return 'runesage'
return IWT2_HEALER_ORDER.includes(value as Iwt2HealerId)
? value as Iwt2HealerId
: 'dawnweaver'
}
function createAbility(
healerId: Iwt2HealerId,
slot: number,
slug: string,
name: string,
icon: string,
kind: Spell['kind'],
power: number,
manaCost: number,
cooldownSeconds: number,
effectType?: string,
): Iwt2HealerAbility {
return {
id: `${healerId}-${slug}`,
healerId,
slot,
name,
icon,
kind,
power,
effectType,
manaCost,
cooldownSeconds,
target: 'party-member',
}
}
+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'
+65
View File
@@ -0,0 +1,65 @@
import {
buildOpponentSlotDebuffChoices,
buildSelfSlotUpgradeChoices,
} from '../../../combat/roguelikeUpgrades'
import type { Spell } from '../../../game'
import { type Iwt2HealerAbility } from './healerAbilities'
export type Iwt2RoguelikeVariant = 'pve' | 'pvp'
export type Iwt2RoguelikeContentType = 'dungeon' | 'raid' | 'stadium'
export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
export type Iwt2RoguelikeSelfBuffId =
| 'revive-party-members'
| `slot${Iwt2RoguelikeSlot}-extra-target`
| `slot${Iwt2RoguelikeSlot}-cost-down`
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
export type Iwt2RoguelikeOpponentDebuffId =
| `opp-slot${Iwt2RoguelikeSlot}-cost-up`
| `opp-slot${Iwt2RoguelikeSlot}-cooldown-up`
export type Iwt2RoguelikeChoice<T extends string> = {
id: T
name: string
description: string
}
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
id: 'revive-party-members',
name: 'Revive Party Members',
description: 'Revive fallen party members before the next IWT2 arena.',
}
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
return {
id: ability.id,
key: String(ability.slot),
name: ability.name,
description: ability.name,
cost: ability.manaCost,
cooldown: ability.cooldownSeconds,
power: ability.power,
glyph: ability.icon,
kind: ability.kind,
effectType: ability.effectType,
}
}
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
slots: IWT2_ROGUELIKE_SLOTS,
spells: abilities.map(iwt2AbilityToSpell),
labelMode: 'ability',
})
}
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
return buildOpponentSlotDebuffChoices<Iwt2RoguelikeOpponentDebuffId>({
slots: IWT2_ROGUELIKE_SLOTS,
spells: abilities.map(iwt2AbilityToSpell),
labelMode: 'ability',
})
}
+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.bosses]
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
}
+220
View File
@@ -0,0 +1,220 @@
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
export type Iwt2InventoryItem = {
id: string
name: string
quantity: number
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
}
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
export type Iwt2CollectionLog = {
bossKills: Record<string, number>
dropsFound: Record<string, number>
}
export type Iwt2Character = {
name: string
level: number
experience: number
healerStyle: Iwt2HealerId
armorPalette: Iwt2ArmorPaletteId
}
export type Iwt2CloudSlot = {
savedAt: number
character: Iwt2Character
inventory: Iwt2InventoryItem[]
collectionLog: Iwt2CollectionLog
}
export type Iwt2Save = {
version: 1
updatedAt: number
character: Iwt2Character
inventory: Iwt2InventoryItem[]
collectionLog: Iwt2CollectionLog
cloudSlot?: Iwt2CloudSlot
}
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,
healerStyle: 'dawnweaver',
armorPalette: 'guild_green',
},
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)),
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
},
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog?.bossKills ?? {},
dropsFound: candidate.collectionLog?.dropsFound ?? {},
},
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
}
}
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,
},
},
}
}
export function updateIwt2CharacterSettings(
save: Iwt2Save,
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
): Iwt2Save {
return {
...save,
updatedAt: Date.now(),
character: {
...save.character,
...settings,
name: normalizeCharacterName(settings.name ?? save.character.name),
healerStyle: asIwt2HealerId(settings.healerStyle ?? save.character.healerStyle),
armorPalette: asIwt2ArmorPaletteId(settings.armorPalette ?? save.character.armorPalette),
},
}
}
export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
return {
...save,
updatedAt: Date.now(),
cloudSlot: {
savedAt: Date.now(),
character: { ...save.character },
inventory: save.inventory.map((item) => ({ ...item })),
collectionLog: {
bossKills: { ...save.collectionLog.bossKills },
dropsFound: { ...save.collectionLog.dropsFound },
},
},
}
}
export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
if (!save.cloudSlot) return save
return {
...save,
updatedAt: Date.now(),
character: { ...save.cloudSlot.character },
inventory: save.cloudSlot.inventory.map((item) => ({ ...item })),
collectionLog: {
bossKills: { ...save.cloudSlot.collectionLog.bossKills },
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
},
}
}
function bossDropFor(bossId: string): { id: string, name: string } {
if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' }
if (bossId === 'great-jaggi') return { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' }
if (bossId === 'khezu') return { id: 'khezu-pearl', name: 'Khezu Pearl' }
return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' }
}
function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId {
return value === 'sun_gold'
|| value === 'ember_red'
|| value === 'moon_blue'
|| value === 'guild_green'
? value
: 'guild_green'
}
function normalizeCharacterName(name: string): string {
const trimmed = name.trim().slice(0, 18)
return trimmed || 'Healer'
}
function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = value as Partial<Iwt2CloudSlot>
if (!candidate.character || !candidate.collectionLog) return undefined
return {
savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : Date.now(),
character: {
name: normalizeCharacterName(candidate.character.name ?? 'Healer'),
level: Math.max(1, Math.floor(candidate.character.level ?? 1)),
experience: Math.max(0, Math.floor(candidate.character.experience ?? 0)),
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
},
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
collectionLog: {
bossKills: candidate.collectionLog.bossKills ?? {},
dropsFound: candidate.collectionLog.dropsFound ?? {},
},
}
}
+803
View File
@@ -0,0 +1,803 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../../../dualScreen'
import type { PartyMember, Role, Spell } from '../../../game'
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_CLASS_METADATA } from '../content/classes'
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'
import type {
Iwt2RoguelikeContentType,
Iwt2RoguelikeOpponentDebuffId,
Iwt2RoguelikeSelfBuffId,
Iwt2RoguelikeVariant,
} from '../content/roguelike'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'menu'
type OverlayNavEntry = {
action: OverlayAction
row: number
}
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'menu', row: 1 },
]
type BossArenaScreenProps = {
bossId: Iwt2BossId
bossIds?: Iwt2BossId[]
modeLabel?: string
save: Iwt2Save
onBack: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[]
onVictory: () => void
stage: number
variant: Iwt2RoguelikeVariant
}
}
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
))
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 opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
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 { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => {
statusRef.current = status
}, [status])
useEffect(() => {
selectedOverlayActionRef.current = selectedOverlayAction
}, [selectedOverlayAction])
useEffect(() => {
selectedPartyIdRef.current = selectedPartyId
}, [selectedPartyId])
useEffect(() => {
saveRef.current = save
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next
opponentStateRef.current = nextOpponentState
setArenaState(next)
setOpponentArenaState(nextOpponentState)
setAbilityCooldowns({})
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
setStatus(nextStatus)
}, [])
const abilities = useMemo(
() => applyRoguelikeModifiers(
abilitiesForHealer(save.character.healerStyle),
roguelikeRun?.buffs ?? [],
roguelikeRun?.debuffs ?? [],
),
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
)
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)
}, [])
const moveOverlaySelection = useCallback((action: string) => {
setSelectedOverlayAction((current) => {
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
if (entry.action === current) return false
if (action === 'navigateUp') return entry.row < active.row
if (action === 'navigateDown') return entry.row > active.row
return false
})
if (candidates.length === 0) return current
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
return candidates[0]?.action ?? current
})
}, [])
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
if (overlayAction === 'menu') {
onBack()
return
}
if (statusRef.current === 'paused') {
setStatus('playing')
return
}
if (statusRef.current === 'victory' && roguelikeRun) {
roguelikeRun.onVictory()
return
}
resetArena()
}, [onBack, resetArena, roguelikeRun])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
if (action.startsWith('navigate')) {
moveOverlaySelection(action)
return
}
if (action === 'confirm') {
activateOverlayAction()
return
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
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
let nextOpponentState = opponentStateRef.current
if (pvpRoguelike && nextOpponentState) {
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
nextOpponentState = tickIwt2Arena(
nextOpponentState,
{
moveX: opponentMovement.x,
moveY: opponentMovement.y,
},
dtSeconds,
)
opponentStateRef.current = nextOpponentState
}
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
if (newlyDefeatedBosses.length > 0) {
const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
updatedSave = recordIwt2BossKill(updatedSave, defeatedBoss.bossId)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
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.bosses.some((boss) => boss.health <= 0)
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time
setArenaState(next)
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [onSaveUpdated, pvpRoguelike, 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 defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
const overlayPrimaryLabel = status === 'paused'
? 'Resume'
: status === 'victory' && roguelikeRun
? 'Choose Upgrade'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossTitle} 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'
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
const pauseCopy = roguelikeRun?.variant === 'pvp'
? undefined
: 'Combat is stopped. Resume the fight or leave the current run.'
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
? 'Leave'
: roguelikeRun
? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}`
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings: activeBindings,
cooldowns: abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel: modeLabel ?? 'Arena',
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}),
[
abilities,
activeBindings,
arenaState,
abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
],
)
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
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 bosses={arenaState.bosses} />
<PartyFrames
controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId}
party={arenaState.party}
selectedPartyId={selectedPartyId}
targetBindings={targetBindings}
/>
<PhaserArena
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
{status === 'paused' && (
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{pauseTitle}</h2>
{pauseCopy && <p>{pauseCopy}</p>}
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Resume
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
{pauseLeaveLabel}
</button>
</div>
</div>
)}
{status !== 'playing' && status !== 'paused' && (
<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>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
</div>
<p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1>
{roguelikeRun && (
<p className="iwt2-result-hint">
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
</p>
)}
<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>{defeatedBossCount}/{arenaState.bosses.length}</strong>
Bosses
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+{arenaState.bosses.length * 125} XP</span>
<span>{arenaState.bosses.length} carves</span>
<span>Log +{arenaState.bosses.length}</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={() => activateOverlayAction('primary')}
type="button"
>
{overlayPrimaryLabel}
</button>
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
type="button"
>
Menu
</button>
</div>
{lastDevice === 'controller' && (
<small className="iwt2-result-hint">D-pad selects, A confirms</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 roguelikeBossHealthScale(stage: number): number {
return 0.5 + Math.max(0, stage - 1) * 0.1
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
...state.bosses.map((boss) => [
boss.id,
Math.ceil(boss.health),
boss.attackPhase,
].join(':')),
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 formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
return bosses
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
.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
}
function applyRoguelikeModifiers(
abilities: Iwt2HealerAbility[],
buffs: Iwt2RoguelikeSelfBuffId[],
debuffs: Iwt2RoguelikeOpponentDebuffId[],
): Iwt2HealerAbility[] {
if (buffs.length === 0 && debuffs.length === 0) return abilities
return abilities.map((ability) => {
const slot = String(ability.slot)
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
return {
...ability,
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
}
})
}
function countStacks(items: readonly string[], id: string) {
return items.filter((item) => item === id).length
}
function roundModifier(value: number) {
return Math.max(0.1, Math.round(value * 100) / 100)
}
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
if (contentType === 'raid') return 'Raid'
if (contentType === 'stadium') return 'Stadium'
return 'Dungeon'
}
function arenaPauseTitle(
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
fallbackTitle: string,
) {
if (!roguelikeRun) return fallbackTitle
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
if (roguelikeRun.variant === 'pvp') {
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
}
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
}
function buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings,
cooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}: {
abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings']
cooldowns: Record<string, number>
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
directPartyTargeting: boolean
modeLabel: string
opponentArenaState: Iwt2ArenaState | null
playerMana: number
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
selectedPartyId: Iwt2EntityId
status: ArenaStatus
}): DualScreenCombatState {
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
const arenaBossHealth = totalBossHealth(arenaState)
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
? {
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
opponentArena: toDualScreenOpponentArena(opponentArenaState),
opponentEnemyHealth: totalBossHealth(opponentArenaState),
opponentMaxResource: opponentHealer?.maxMana ?? 100,
opponentName: 'CPU Rival',
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
opponentResource: opponentHealer?.mana ?? 100,
opponentResourceName: 'Mana',
}
: {}
return {
...pvpOpponentState,
bindings,
contentName: modeLabel,
controllerIconStyle,
difficultyName: 'IWT2',
directPartyTargeting,
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaBossHealth,
encounterIndex: 0,
encounterIsBoss: true,
encounterMaxHealth: arenaBossMaxHealth,
encounterName: formatBossEncounterTitle(arenaState.bosses),
floatingTexts: [],
maxResource: 100,
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
partySize: arenaState.party.length,
paused: status === 'paused',
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
resource: playerMana,
resourceName: 'Mana',
selectedId: selectedPartyId,
speedMultiplier: 1,
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
targetGroup: 0,
}
}
function totalBossHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
}
function totalBossMaxHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
}
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
return {
bounds: state.bounds,
bosses: state.bosses.map((boss) => {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
name: metadata.name,
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
}
}),
party: state.party.map((member) => {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
isHealer: member.id === 'player-healer',
}
}),
}
}
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
bounceHeals: [],
health: member.health,
hotEffects: member.hotEffects.map((effect) => ({
id: effect.id,
label: effect.label,
power: effect.power,
spellId: effect.id,
ticks: Math.ceil(effect.remainingSeconds),
})),
hotTicks: member.hotEffects.length,
id: opponent ? `opponent-${member.id}` : member.id,
maxHealth: member.maxHealth,
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
role: toDualScreenRole(metadata.role),
shield: member.shield,
}
}
function opponentPartyName(name: string): string {
if (name === 'Player Healer') return 'CPU Healer'
return `CPU ${name.replace(' Tank', '')}`
}
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
if (role === 'tank') return 'Tank'
if (role === 'healer') return 'Healer'
return 'Damage'
}
function toDualScreenSpell(
ability: Iwt2HealerAbility,
slotIndex: number,
remaining: number,
): Spell & { slotIndex: number; remaining: number } {
return {
cooldown: ability.cooldownSeconds,
cost: ability.manaCost,
description: 'IWT2 arena ability',
effectType: ability.effectType,
glyph: ability.icon,
id: ability.id,
key: String(ability.slot),
kind: ability.kind,
name: ability.name,
power: ability.power,
remaining,
slotIndex,
}
}
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
const healer = state.party.find((member) => member.id === 'player-healer')
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
const orbitSeconds = Math.max(1, state.time)
return normalizedArenaMovement({
x: Math.cos(orbitSeconds * 0.75),
y: Math.sin(orbitSeconds * 0.75) * 0.55,
})
}
function normalizedArenaMovement(vector: MovementVector): MovementVector {
const magnitude = Math.hypot(vector.x, vector.y)
if (magnitude <= 1) return vector
return {
x: vector.x / magnitude,
y: vector.y / magnitude,
}
}
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
if (debuffs.length === 0) return 'none'
return debuffs
.map((debuff) => debuff
.replace('opp-', '')
.replaceAll('-', ' '))
.join(', ')
}
File diff suppressed because it is too large Load Diff
+759
View File
@@ -0,0 +1,759 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaState,
Iwt2BossEntityState,
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
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
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',
bossIds?: Iwt2BossId[],
bossHealthScale = 1,
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
boss: bosses[0],
bosses,
nextEventId: 1,
nextProjectileId: 1,
nextAddId: 1,
nextHazardId: 1,
events: [],
}
}
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return {
id: bossId,
kind: 'boss',
bossId,
position,
velocity: { x: 0, y: 0 },
facing: { x: -1, y: 0 },
radius: bossMetadata.radius,
health: maxHealth,
maxHealth,
meleeCooldownRemaining: 0.6 + index * 0.25,
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
chargeCount: 0,
attackPhase: 'idle',
phaseSecondsRemaining: 0,
chargeStart: { ...position },
chargeEnd: { ...position },
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
}
}
function initialBossPosition(index: number) {
return {
x: index === 0 ? 660 : 760,
y: index === 0 ? 190 : 345,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
if (bossId === 'khezu') return 3
return 0
}
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
if (bossId === 'yian-kut-ku') return 1.2
if (bossId === 'khezu') return 1.6
return 0
}
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: separatePartyFromBosses(baseState.party, baseState),
}
const bossResult = tickBosses(separatedState, step)
const projectileResult = advanceProjectiles(
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
bossResult.party,
bossResult.bosses,
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.bosses,
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: getPrimaryBoss(damageResult.bosses),
bosses: damageResult.bosses,
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 tickBosses(state: Iwt2ArenaState, dt: number) {
let nextParty = state.party
let nextHostileAdds = state.hostileAdds
let nextHazards = state.hazards
let nextProjectileId = state.nextProjectileId
let nextAddId = state.nextAddId
let nextHazardId = state.nextHazardId
const bosses: Iwt2BossEntityState[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
const events: Iwt2ArenaEvent[] = []
const indicators: Iwt2ArenaIndicator[] = []
for (const boss of state.bosses) {
const bossState = {
...state,
boss,
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
nextProjectileId,
nextAddId,
nextHazardId,
}
const result = tickBoss(bossState, dt)
bosses.push(result.boss)
nextParty = result.party
nextHostileAdds = result.hostileAdds ?? nextHostileAdds
nextHazards = result.hazards ?? nextHazards
nextProjectileId = result.nextProjectileId ?? nextProjectileId
nextAddId = result.nextAddId ?? nextAddId
nextHazardId = result.nextHazardId ?? nextHazardId
projectiles.push(...(result.projectiles ?? []))
events.push(...result.events)
indicators.push(...result.indicators)
}
return {
bosses,
boss: getPrimaryBoss(bosses),
party: nextParty,
hostileAdds: nextHostileAdds,
hazards: nextHazards,
projectiles,
events,
indicators,
nextAddId,
nextHazardId,
nextProjectileId,
}
}
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
return party.map((member) => {
if (member.health <= 0) return member
let position = member.position
for (const boss of state.bosses) {
if (boss.health <= 0) continue
position = separateCircles(
{ position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
}
return { ...member, position }
})
}
function advanceProjectiles(
projectiles: Iwt2ProjectileEntityState[],
party: Iwt2PartyEntityState[],
bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[],
hazards: Iwt2GroundHazardState[],
nextHazardId: number,
nextProjectileId: number,
bounds: Iwt2ArenaState['bounds'],
time: number,
dt: number,
): {
bosses: Iwt2BossEntityState[]
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextProjectileId: number
projectiles: Iwt2ProjectileEntityState[]
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
let nextBosses = bosses
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 (nextBosses.every((boss) => boss.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
}
const hitBoss = nextBosses.find((boss) => (
boss.health > 0
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
))
if (hitBoss) {
const damage = Math.min(projectile.damage, hitBoss.health)
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
? { ...boss, health: Math.max(0, boss.health - damage) }
: boss)
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
events.push({
id: 0,
time,
type: 'bossDamaged',
sourceId: projectile.sourceId,
targetId: hitBoss.id,
value: damage,
})
if (hitBoss.health - damage <= 0) {
events.push({
id: 0,
time,
type: 'entityDefeated',
sourceId: projectile.sourceId,
targetId: hitBoss.id,
})
}
continue
}
nextProjectiles.push({
...projectile,
position: nextPosition,
remainingSeconds,
})
}
return {
bosses: nextBosses,
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[],
bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[],
time: number,
nextProjectileId: number,
): {
bosses: Iwt2BossEntityState[]
party: Iwt2PartyEntityState[]
hostileAdds: Iwt2HostileAddState[]
projectiles: Iwt2ProjectileEntityState[]
nextProjectileId: number
events: Iwt2ArenaEvent[]
} {
const events: Iwt2ArenaEvent[] = []
const projectiles: Iwt2ProjectileEntityState[] = []
let nextBosses = bosses
let nextHostileAdds = hostileAdds
let projectileId = nextProjectileId
const nextParty = party.map((member) => {
const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position, target.radius)) 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') {
nextBosses = nextBosses.map((boss) => boss.id === target.id
? { ...boss, health: Math.max(0, boss.health - damage) }
: boss)
} 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 {
bosses: nextBosses,
party: nextParty,
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
projectiles,
nextProjectileId: projectileId,
events,
}
}
function getPriorityAttackTarget(
member: Iwt2PartyEntityState,
bosses: Iwt2BossEntityState[],
hostileAdds: Iwt2HostileAddState[],
): (Iwt2BossEntityState | 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])
}
const livingBosses = bosses.filter((boss) => boss.health > 0)
if (livingBosses.length === 0) return undefined
return livingBosses.reduce((best, boss) => (
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
), livingBosses[0])
}
function getPrimaryBoss(bosses: Iwt2BossEntityState[]): Iwt2BossEntityState {
return bosses.find((boss) => boss.health > 0) ?? bosses[0]
}
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 }))
}
+152
View File
@@ -0,0 +1,152 @@
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,
bossIds?: Iwt2BossId[],
bossHealthScale?: number,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale))
}
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),
...state.bosses.map(toBossArenaEntity),
],
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 []
})
}
+382
View File
@@ -0,0 +1,382 @@
import { BULLDROME_BOSS_METADATA } from '../content/bosses'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2Vec2,
} from './types'
import { tickGreatJaggi } from './greatJaggiAi'
import { tickKhezu } from './khezuAi'
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)
if (state.boss.bossId === 'great-jaggi') return tickGreatJaggi(state, dt)
if (state.boss.bossId === 'khezu') return tickKhezu(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)
}
+211
View File
@@ -0,0 +1,211 @@
import { GREAT_JAGGI_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
} from './types'
import {
applyPartyDamageInShape,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickGreatJaggi(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
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),
velocity: { x: 0, y: 0 },
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withGreatJaggiIndicators({ boss, party, events })
if (boss.attackPhase === 'packHowlWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyPackLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'packHowlRecover',
phaseSecondsRemaining: 0.45,
}
}
return withGreatJaggiIndicators({ boss, party, events })
}
if (boss.attackPhase === 'packHowlRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withGreatJaggiIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'packHowlWindup',
chargeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlCooldown!,
mechanicLanes: createPackLanes(state, party),
phaseSecondsRemaining: GREAT_JAGGI_BOSS_METADATA.packHowlWindup!,
velocity: { x: 0, y: 0 },
}
return withGreatJaggiIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withGreatJaggiIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, GREAT_JAGGI_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 withGreatJaggiIndicators({ boss, party, events })
}
function createPackLanes(state: Iwt2ArenaState, party: Iwt2PartyEntityState[]): Iwt2MechanicLaneState[] {
const living = party.filter((member) => member.health > 0)
const count = GREAT_JAGGI_BOSS_METADATA.packLaneCount!
const lanes: Iwt2MechanicLaneState[] = []
for (let index = 0; index < count; index += 1) {
const target = living[(index * 2) % Math.max(1, living.length)]
const baseY = target
? target.position.y
: state.bounds.height * ((index + 1) / (count + 1))
const slope = index % 2 === 0 ? 54 : -54
const y = clamp(baseY + (index - 1) * 28, 54, state.bounds.height - 54)
lanes.push({
id: `pack-lane-${index}`,
start: { x: -28, y: clamp(y - slope, 36, state.bounds.height - 36) },
end: { x: state.bounds.width + 28, y: clamp(y + slope, 36, state.bounds.height - 36) },
width: GREAT_JAGGI_BOSS_METADATA.packLaneWidth!,
})
}
return lanes
}
function applyPackLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
start: lane.start,
end: lane.end,
width: lane.width,
}, {
damage: GREAT_JAGGI_BOSS_METADATA.packLaneDamage!,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: GREAT_JAGGI_BOSS_METADATA.packLaneStunSeconds!,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
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,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > GREAT_JAGGI_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: GREAT_JAGGI_BOSS_METADATA.meleeRange,
}, {
damage: GREAT_JAGGI_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: GREAT_JAGGI_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withGreatJaggiIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createGreatJaggiIndicators(result.boss),
}
}
function createGreatJaggiIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
if (
boss.attackPhase !== 'packHowlWindup'
&& boss.attackPhase !== 'packHowlRecover'
) {
return []
}
return boss.mechanicLanes.map((lane) => createLaneIndicator({
color: boss.attackPhase === 'packHowlRecover' ? '#f05b4f' : '#b8f0aa',
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'great-jaggi-pack-lane',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'packHowlWindup',
boss.attackPhase === 'packHowlRecover',
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
+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)
}

Some files were not shown because too many files have changed in this diff Show More