Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d269a6041 | ||
|
|
99e97e5208 | ||
|
|
a849cd2c69 | ||
|
|
a121b2ef74 |
@@ -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.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 91
|
||||
versionName "1.1.12"
|
||||
versionCode 95
|
||||
versionName "1.1.16"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+2
-1
@@ -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 |
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+1161
-7
File diff suppressed because it is too large
Load Diff
+68
-1483
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>({
|
||||
|
||||
+108
-14
@@ -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,
|
||||
|
||||
+85
-3
@@ -13,6 +13,10 @@ import { Capacitor } from '@capacitor/core'
|
||||
|
||||
export type InputDevice = 'pc' | 'controller'
|
||||
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
|
||||
export type MovementVector = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export const INPUT_ACTIONS = [
|
||||
'navigateUp',
|
||||
@@ -131,7 +135,7 @@ const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
|
||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display'
|
||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
||||
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
|
||||
const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
||||
@@ -235,13 +239,13 @@ function loadPreferences() {
|
||||
combatTouchLocked?: boolean
|
||||
}
|
||||
return {
|
||||
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
|
||||
controllerIconStyle: saved.controllerIconStyle ?? 'playstation',
|
||||
directPartyTargeting: saved.directPartyTargeting ?? false,
|
||||
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
||||
controllerIconStyle: 'playstation' as ControllerIconStyle,
|
||||
directPartyTargeting: false,
|
||||
combatTouchLocked: Capacitor.isNativePlatform(),
|
||||
}
|
||||
@@ -530,6 +534,10 @@ function isCombatActive() {
|
||||
return Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
}
|
||||
|
||||
function hasContinuousMovementActive() {
|
||||
return Boolean(document.querySelector('[data-continuous-movement-active="true"]'))
|
||||
}
|
||||
|
||||
function firstConnectedGamepad() {
|
||||
return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
|
||||
}
|
||||
@@ -686,6 +694,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
document.querySelector('[data-combat-active="true"]'),
|
||||
)
|
||||
const uiOverlay = hasUiOverlay()
|
||||
if (
|
||||
combatActive
|
||||
&& !uiOverlay
|
||||
&& hasContinuousMovementActive()
|
||||
&& (token.startsWith('Axis0') || token.startsWith('Axis1'))
|
||||
) return
|
||||
const uiPriority = [
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
@@ -1046,6 +1060,74 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedVector(x: number, y: number): MovementVector {
|
||||
const magnitude = Math.hypot(x, y)
|
||||
if (magnitude <= 1) return { x, y }
|
||||
return { x: x / magnitude, y: y / magnitude }
|
||||
}
|
||||
|
||||
function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector {
|
||||
const magnitude = Math.hypot(x, y)
|
||||
if (magnitude < deadzone) return { x: 0, y: 0 }
|
||||
const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone))
|
||||
return {
|
||||
x: (x / magnitude) * scaled,
|
||||
y: (y / magnitude) * scaled,
|
||||
}
|
||||
}
|
||||
|
||||
export function useMovementVectorRef(enabled = true) {
|
||||
const movementRef = useRef<MovementVector>({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
movementRef.current = { x: 0, y: 0 }
|
||||
return undefined
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
const pressedKeys = new Set<string>()
|
||||
const keyboardVector = () => normalizedVector(
|
||||
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
||||
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
||||
)
|
||||
const updateMovement = () => {
|
||||
const gamepad = firstConnectedGamepad()
|
||||
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
|
||||
const keyboard = keyboardVector()
|
||||
movementRef.current = Math.hypot(stick.x, stick.y) > 0
|
||||
? stick
|
||||
: keyboard
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isTextInput(document.activeElement)) return
|
||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||
pressedKeys.add(event.code)
|
||||
document.documentElement.dataset.inputDevice = 'pc'
|
||||
event.preventDefault()
|
||||
}
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||
pressedKeys.delete(event.code)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
movementRef.current = { x: 0, y: 0 }
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return movementRef
|
||||
}
|
||||
|
||||
export function useInput() {
|
||||
const context = useContext(InputContext)
|
||||
if (!context) throw new Error('useInput must be used inside InputProvider')
|
||||
|
||||
+9
-1
@@ -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
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGameAction } from '../../input'
|
||||
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
||||
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 type { Iwt2BossId } from './content/bosses'
|
||||
import { abilitiesForHealer } 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 = {
|
||||
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 [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)
|
||||
|
||||
useEffect(() => {
|
||||
writeIwt2Save(save)
|
||||
}, [save])
|
||||
|
||||
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}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike-arena' && roguelikeRun) {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
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(bossForRoguelike(nextRun.variant, nextRun.contentType, nextRun.stage))
|
||||
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={() => {
|
||||
const nextRun = createRoguelikeRun(save, roguelikeVariant, roguelikeContentType)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(bossForRoguelike(roguelikeVariant, roguelikeContentType, nextRun.stage))
|
||||
setScreen('roguelike-arena')
|
||||
}}
|
||||
onVariantChange={(nextVariant) => {
|
||||
setRoguelikeVariant(nextVariant)
|
||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||
setRoguelikeContentType('dungeon')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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"
|
||||
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 bossForRoguelike(
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
stage = 1,
|
||||
): Iwt2BossId {
|
||||
const dungeonBosses: Iwt2BossId[] = variant === 'pvp'
|
||||
? ['yian-kut-ku', 'great-jaggi', 'bulldrome', 'khezu']
|
||||
: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
||||
const raidBosses: Iwt2BossId[] = ['khezu', 'great-jaggi', 'yian-kut-ku', 'bulldrome']
|
||||
const stadiumBosses: Iwt2BossId[] = ['great-jaggi', 'yian-kut-ku', 'khezu', 'bulldrome']
|
||||
const pool = contentType === 'raid'
|
||||
? raidBosses
|
||||
: contentType === 'stadium'
|
||||
? stadiumBosses
|
||||
: dungeonBosses
|
||||
return pool[(stage - 1) % pool.length]
|
||||
}
|
||||
|
||||
function createRoguelikeRun(
|
||||
save: Iwt2Save,
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
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,
|
||||
...buildRoguelikeChoices(save, run.variant),
|
||||
stage: run.stage + 1,
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Iwt2BossEntityState } from '../sim'
|
||||
import { ArenaBar } from './ArenaBars'
|
||||
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
||||
|
||||
export function BossHud({ boss }: { boss: Iwt2BossEntityState }) {
|
||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
return (
|
||||
<div className="iwt2-boss-hud">
|
||||
<div>
|
||||
<strong>{metadata.name}</strong>
|
||||
<small>{Math.ceil(boss.health)} / {boss.maxHealth} HP</small>
|
||||
</div>
|
||||
<small className="iwt2-boss-phase">{boss.attackPhase}</small>
|
||||
<ArenaBar className="boss" current={boss.health} max={boss.maxHealth} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ControllerBindingLabel } from '../../../components/ControllerIcons'
|
||||
import type { ControllerIconStyle } from '../../../input'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import type { Iwt2EntityId, Iwt2PartyEntityState } from '../sim'
|
||||
import { ArenaBar } from './ArenaBars'
|
||||
|
||||
export function PartyFrames({
|
||||
party,
|
||||
selectedPartyId,
|
||||
targetBindings,
|
||||
controllerIconStyle,
|
||||
onTarget,
|
||||
}: {
|
||||
party: Iwt2PartyEntityState[]
|
||||
selectedPartyId: Iwt2EntityId
|
||||
targetBindings?: Array<string | null>
|
||||
controllerIconStyle?: ControllerIconStyle
|
||||
onTarget: (id: Iwt2EntityId) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="iwt2-party-list" data-game-nav-active="true">
|
||||
{party.map((member) => {
|
||||
const meta = IWT2_CLASS_METADATA[member.classId]
|
||||
const selected = member.id === selectedPartyId
|
||||
const targetBinding = targetBindings?.[party.indexOf(member)]
|
||||
return (
|
||||
<button
|
||||
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
key={member.id}
|
||||
onClick={() => onTarget(member.id)}
|
||||
type="button"
|
||||
>
|
||||
{targetBinding && (
|
||||
<span className="iwt2-party-target-key">
|
||||
<ControllerBindingLabel
|
||||
binding={targetBinding}
|
||||
iconStyle={controllerIconStyle ?? 'playstation'}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span style={{ background: meta.color }}>{meta.icon}</span>
|
||||
<div>
|
||||
<div className="iwt2-party-row-title">
|
||||
<strong>{meta.name}</strong>
|
||||
<small>{Math.ceil(member.health)}</small>
|
||||
</div>
|
||||
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
|
||||
{member.maxMana > 0 && (
|
||||
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
||||
)}
|
||||
<div className="iwt2-party-effects">
|
||||
{member.hotEffects.map((effect) => (
|
||||
<span className="iwt2-effect-badge hot" key={effect.id}>
|
||||
{effect.label} {Math.ceil(effect.remainingSeconds)}s
|
||||
</span>
|
||||
))}
|
||||
{member.shield > 0 && (
|
||||
<span className="iwt2-effect-badge shield">
|
||||
Shield {Math.ceil(member.shield)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<small className="iwt2-party-damage">Damage {Math.round(member.damageDone)}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Iwt2BalanceOverrides } from './bosses'
|
||||
|
||||
// Generated by local admin panel. Commit this file with intended IWT2 balance changes.
|
||||
export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = {
|
||||
bosses: {},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import Phaser from 'phaser'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
|
||||
import type { Iwt2ArenaState } from '../sim/arenaState'
|
||||
|
||||
type PhaserArenaProps = {
|
||||
movementRef: MutableRefObject<MovementVector>
|
||||
selectedPartyIdRef: MutableRefObject<string>
|
||||
stateRef: MutableRefObject<Iwt2ArenaState>
|
||||
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
|
||||
}
|
||||
|
||||
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return undefined
|
||||
const initialState = stateRef.current
|
||||
const scene = new BulldromeArenaScene({
|
||||
getMovement: () => movementRef.current,
|
||||
getSelectedPartyId: () => selectedPartyIdRef.current,
|
||||
getState: () => stateRef.current,
|
||||
step: onStep,
|
||||
})
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: hostRef.current,
|
||||
width: initialState.bounds.width,
|
||||
height: initialState.bounds.height,
|
||||
backgroundColor: '#10141b',
|
||||
pixelArt: false,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
scene,
|
||||
})
|
||||
|
||||
return () => {
|
||||
game.destroy(true)
|
||||
}
|
||||
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
|
||||
|
||||
return <div className="iwt2-phaser-host" ref={hostRef} />
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import Phaser from 'phaser'
|
||||
import type { MovementVector } from '../../../../input'
|
||||
import { IWT2_BOSS_METADATA } from '../../content/bosses'
|
||||
import { IWT2_CLASS_METADATA } from '../../content/classes'
|
||||
import type {
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2HostileAddState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from '../../sim'
|
||||
|
||||
type SceneDeps = {
|
||||
getMovement: () => MovementVector
|
||||
getSelectedPartyId: () => string
|
||||
getState: () => Iwt2ArenaState
|
||||
step: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
|
||||
}
|
||||
|
||||
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
|
||||
|
||||
export class BulldromeArenaScene extends Phaser.Scene {
|
||||
private deps: SceneDeps
|
||||
private arenaGraphics?: Phaser.GameObjects.Graphics
|
||||
private entityGraphics?: Phaser.GameObjects.Graphics
|
||||
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
||||
private labels = new Map<string, Phaser.GameObjects.Text>()
|
||||
private lastHudPublish = 0
|
||||
|
||||
constructor(deps: SceneDeps) {
|
||||
super('bulldrome-arena')
|
||||
this.deps = deps
|
||||
}
|
||||
|
||||
create() {
|
||||
this.cameras.main.setBackgroundColor('#10141b')
|
||||
this.arenaGraphics = this.add.graphics()
|
||||
this.telegraphGraphics = this.add.graphics()
|
||||
this.entityGraphics = this.add.graphics()
|
||||
this.drawState(this.deps.getState())
|
||||
}
|
||||
|
||||
update(_: number, delta: number) {
|
||||
const state = this.deps.step(this.deps.getMovement(), Math.min(0.05, delta / 1000))
|
||||
this.drawState(state)
|
||||
}
|
||||
|
||||
private drawState(state: Iwt2ArenaState) {
|
||||
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
|
||||
this.drawArena(state)
|
||||
this.drawTelegraphs(state)
|
||||
this.drawEntities(state)
|
||||
this.drawProjectiles(state)
|
||||
this.lastHudPublish += 1
|
||||
}
|
||||
|
||||
private drawArena(state: Iwt2ArenaState) {
|
||||
const graphics = this.arenaGraphics!
|
||||
graphics.clear()
|
||||
graphics.lineStyle(3, 0x3c4a59, 1)
|
||||
graphics.fillStyle(0x171d24, 1)
|
||||
graphics.fillRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12)
|
||||
graphics.strokeRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12)
|
||||
|
||||
graphics.lineStyle(1, 0x26313d, 0.55)
|
||||
for (let x = 80; x < state.bounds.width; x += 80) {
|
||||
graphics.lineBetween(x, 0, x, state.bounds.height)
|
||||
}
|
||||
for (let y = 80; y < state.bounds.height; y += 80) {
|
||||
graphics.lineBetween(0, y, state.bounds.width, y)
|
||||
}
|
||||
}
|
||||
|
||||
private drawTelegraphs(state: Iwt2ArenaState) {
|
||||
const graphics = this.telegraphGraphics!
|
||||
graphics.clear()
|
||||
state.indicators.forEach((indicator) => drawIndicator(graphics, indicator))
|
||||
}
|
||||
|
||||
private drawEntities(state: Iwt2ArenaState) {
|
||||
const graphics = this.entityGraphics!
|
||||
graphics.clear()
|
||||
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
||||
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss]
|
||||
const liveIds = new Set<string>(entities.map((entity) => entity.id))
|
||||
|
||||
for (const entity of entities.sort(entitySort)) {
|
||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
||||
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
|
||||
const color = entityColor(entity)
|
||||
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
|
||||
graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1)
|
||||
if (entity.kind === 'boss') {
|
||||
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
|
||||
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
|
||||
} else if (entity.kind === 'hostileAdd') {
|
||||
const wing = entity.radius * 1.55
|
||||
graphics.fillTriangle(
|
||||
entity.position.x + entity.facing.x * wing,
|
||||
entity.position.y,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y - entity.radius,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y + entity.radius,
|
||||
)
|
||||
graphics.strokeTriangle(
|
||||
entity.position.x + entity.facing.x * wing,
|
||||
entity.position.y,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y - entity.radius,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y + entity.radius,
|
||||
)
|
||||
} else {
|
||||
if (entity.id === this.deps.getSelectedPartyId()) {
|
||||
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
||||
}
|
||||
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
||||
}
|
||||
this.drawLabel(entity)
|
||||
this.drawHealthBar(graphics, entity)
|
||||
}
|
||||
|
||||
for (const [id, label] of this.labels) {
|
||||
if (!liveIds.has(id)) {
|
||||
label.destroy()
|
||||
this.labels.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawProjectiles(state: Iwt2ArenaState) {
|
||||
const graphics = this.entityGraphics!
|
||||
for (const projectile of state.projectiles) {
|
||||
const color = Number.parseInt(projectile.color.slice(1), 16)
|
||||
graphics.fillStyle(color, 0.95)
|
||||
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
|
||||
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
|
||||
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
|
||||
}
|
||||
}
|
||||
|
||||
private drawLabel(entity: DrawableEntity) {
|
||||
let label = this.labels.get(entity.id)
|
||||
const icon = entityIcon(entity)
|
||||
if (!label) {
|
||||
label = this.add.text(entity.position.x, entity.position.y, icon, {
|
||||
align: 'center',
|
||||
color: '#fff7df',
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
fontSize: entity.kind === 'boss' ? '20px' : entity.kind === 'hostileAdd' ? '13px' : '15px',
|
||||
fontStyle: '900',
|
||||
}).setOrigin(0.5)
|
||||
this.labels.set(entity.id, label)
|
||||
}
|
||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
||||
label.setText(stunned ? '!' : icon)
|
||||
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
|
||||
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
|
||||
}
|
||||
|
||||
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
|
||||
const width = entity.kind === 'boss' ? 92 : 34
|
||||
const height = entity.kind === 'boss' ? 8 : 7
|
||||
const y = entity.position.y + entity.radius + 8
|
||||
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
|
||||
graphics.fillStyle(0x050608, 0.9)
|
||||
graphics.fillRect(entity.position.x - width / 2, y, width, height)
|
||||
graphics.fillStyle(ratio > 0.5 ? 0x4ac77d : ratio > 0.25 ? 0xf1c663 : 0xf05b4f, 1)
|
||||
graphics.fillRect(entity.position.x - width / 2, y, width * ratio, height)
|
||||
if (entity.kind === 'party' && entity.shield > 0) {
|
||||
const shieldRatio = Math.max(0, Math.min(1, entity.shield / entity.maxHealth))
|
||||
graphics.fillStyle(0x58a8ff, 0.92)
|
||||
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
|
||||
const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record<DrawableEntity['kind'], number>
|
||||
return rank[a.kind] - rank[b.kind]
|
||||
}
|
||||
|
||||
function entityColor(entity: DrawableEntity): string {
|
||||
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
|
||||
if (entity.kind === 'hostileAdd') return '#f0b84f'
|
||||
return IWT2_CLASS_METADATA[entity.classId].color
|
||||
}
|
||||
|
||||
function entityIcon(entity: DrawableEntity): string {
|
||||
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].icon
|
||||
if (entity.kind === 'hostileAdd') return 'v'
|
||||
return IWT2_CLASS_METADATA[entity.classId].icon
|
||||
}
|
||||
|
||||
function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) {
|
||||
const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds)))
|
||||
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
|
||||
graphics.fillStyle(0xff7a2f, alpha)
|
||||
graphics.lineStyle(2, 0xffc15a, 0.55)
|
||||
graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
||||
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
||||
}
|
||||
|
||||
function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2ArenaIndicator) {
|
||||
const color = Number.parseInt(indicator.color.slice(1), 16)
|
||||
const active = indicator.phase === 'active'
|
||||
graphics.lineStyle(active ? 8 : 5, color, indicator.lineAlpha ?? (active ? 0.76 : 0.5))
|
||||
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
|
||||
|
||||
if (indicator.kind === 'lane') {
|
||||
const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width)
|
||||
graphics.fillPoints(points, true)
|
||||
graphics.strokePoints(points, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (indicator.kind === 'circle') {
|
||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.radius)
|
||||
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.radius)
|
||||
return
|
||||
}
|
||||
|
||||
if (indicator.kind === 'cone') {
|
||||
const points = coneDangerPolygon(
|
||||
indicator.origin,
|
||||
indicator.direction,
|
||||
indicator.range,
|
||||
indicator.angleRadians,
|
||||
)
|
||||
graphics.fillPoints(points, true)
|
||||
graphics.strokePoints(points, true)
|
||||
return
|
||||
}
|
||||
|
||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
|
||||
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
|
||||
graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
|
||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
|
||||
}
|
||||
|
||||
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
|
||||
const dx = end.x - start.x
|
||||
const dy = end.y - start.y
|
||||
const length = Math.max(1, Math.hypot(dx, dy))
|
||||
const normal = {
|
||||
x: (-dy / length) * halfWidth,
|
||||
y: (dx / length) * halfWidth,
|
||||
}
|
||||
return [
|
||||
new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y),
|
||||
new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y),
|
||||
new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y),
|
||||
new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y),
|
||||
]
|
||||
}
|
||||
|
||||
function coneDangerPolygon(
|
||||
origin: Iwt2Vec2,
|
||||
direction: Iwt2Vec2,
|
||||
range: number,
|
||||
angleRadians: number,
|
||||
) {
|
||||
const baseAngle = Math.atan2(direction.y, direction.x)
|
||||
const halfAngle = angleRadians / 2
|
||||
const points = [new Phaser.Math.Vector2(origin.x, origin.y)]
|
||||
const segmentCount = 14
|
||||
for (let index = 0; index <= segmentCount; index += 1) {
|
||||
const t = index / segmentCount
|
||||
const angle = baseAngle - halfAngle + angleRadians * t
|
||||
points.push(new Phaser.Math.Vector2(
|
||||
origin.x + Math.cos(angle) * range,
|
||||
origin.y + Math.sin(angle) * range,
|
||||
))
|
||||
}
|
||||
return points
|
||||
}
|
||||
@@ -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 ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||
import {
|
||||
createInitialIwt2ArenaState,
|
||||
tickIwt2Arena,
|
||||
type Iwt2ArenaState,
|
||||
} from '../sim/arenaState'
|
||||
import type { Iwt2EntityId } from '../sim'
|
||||
import { castIwt2HealerAbility } from '../sim'
|
||||
import { PhaserArena } from '../render/PhaserArena'
|
||||
import {
|
||||
recordIwt2BossKill,
|
||||
type Iwt2Save,
|
||||
} from '../save/iwt2Repository'
|
||||
import { AbilityBar } from '../components/AbilityBar'
|
||||
import { BossHud } from '../components/BossHud'
|
||||
import { PartyFrames } from '../components/PartyFrames'
|
||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
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
|
||||
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, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId))
|
||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||
const stateRef = useRef(arenaState)
|
||||
const statusRef = useRef(status)
|
||||
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||
const saveRef = useRef(save)
|
||||
const killRecordedRef = useRef(false)
|
||||
const lastPublishTimeRef = useRef(0)
|
||||
const lastHudSignatureRef = useRef('')
|
||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||
const movementRef = useMovementVectorRef(status === 'playing')
|
||||
const {
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const activeBindings = bindings[lastDevice]
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = arenaState
|
||||
}, [arenaState])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
selectedOverlayActionRef.current = selectedOverlayAction
|
||||
}, [selectedOverlayAction])
|
||||
|
||||
useEffect(() => {
|
||||
selectedPartyIdRef.current = selectedPartyId
|
||||
}, [selectedPartyId])
|
||||
|
||||
useEffect(() => {
|
||||
saveRef.current = save
|
||||
}, [save])
|
||||
|
||||
const resetArena = useCallback(() => {
|
||||
const next = createInitialIwt2ArenaState(bossId)
|
||||
killRecordedRef.current = false
|
||||
abilityCooldownsRef.current = {}
|
||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||
stateRef.current = next
|
||||
setArenaState(next)
|
||||
setAbilityCooldowns({})
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus('playing')
|
||||
}, [bossId])
|
||||
|
||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus(nextStatus)
|
||||
}, [])
|
||||
|
||||
const abilities = useMemo(
|
||||
() => 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')
|
||||
else onBack()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (statusRef.current === 'playing' && action.startsWith('ability')) {
|
||||
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
|
||||
if (ability) castAbility(ability)
|
||||
return
|
||||
}
|
||||
if (
|
||||
device === 'controller'
|
||||
&& statusRef.current === 'playing'
|
||||
&& !directPartyTargeting
|
||||
&& (
|
||||
action === 'navigateUp'
|
||||
|| action === 'navigateLeft'
|
||||
|| action === 'previousTarget'
|
||||
|| action === 'navigateDown'
|
||||
|| action === 'navigateRight'
|
||||
|| action === 'nextTarget'
|
||||
)
|
||||
) {
|
||||
const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget'
|
||||
setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1))
|
||||
return
|
||||
}
|
||||
if (action.startsWith('targetParty')) {
|
||||
const index = Number(action.replace('targetParty', '')) - 1
|
||||
const target = stateRef.current.party[index]
|
||||
if (target) setSelectedPartyId(target.id)
|
||||
return
|
||||
}
|
||||
if (device !== 'controller') return
|
||||
if (action === 'pause' || action === 'back') {
|
||||
if (statusRef.current === 'playing') showOverlay('paused')
|
||||
else if (statusRef.current === 'paused') setStatus('playing')
|
||||
}
|
||||
})
|
||||
|
||||
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
|
||||
if (statusRef.current !== 'playing') return stateRef.current
|
||||
abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds)
|
||||
const next = tickIwt2Arena(stateRef.current, {
|
||||
moveX: movement.x,
|
||||
moveY: movement.y,
|
||||
}, dtSeconds)
|
||||
stateRef.current = next
|
||||
|
||||
if (next.boss.health <= 0 && !killRecordedRef.current) {
|
||||
killRecordedRef.current = true
|
||||
const updatedSave = recordIwt2BossKill(saveRef.current, bossId)
|
||||
saveRef.current = updatedSave
|
||||
onSaveUpdated(updatedSave)
|
||||
showOverlay('victory')
|
||||
} else if (next.party.every((member) => member.health <= 0)) {
|
||||
showOverlay('defeat')
|
||||
}
|
||||
|
||||
const hudSignature = arenaHudSignature(next)
|
||||
if (
|
||||
hudSignature !== lastHudSignatureRef.current
|
||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
||||
|| next.boss.health <= 0
|
||||
) {
|
||||
lastHudSignatureRef.current = hudSignature
|
||||
lastPublishTimeRef.current = next.time
|
||||
setArenaState(next)
|
||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||
}
|
||||
return next
|
||||
}, [bossId, onSaveUpdated, showOverlay])
|
||||
|
||||
const targetBindings = directPartyTargeting
|
||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
||||
: undefined
|
||||
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
||||
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
||||
const overlayPrimaryLabel = status === 'paused'
|
||||
? 'Resume'
|
||||
: status === 'victory' && roguelikeRun
|
||||
? 'Choose Upgrade'
|
||||
: 'Restart'
|
||||
const overlayTitle = status === 'victory'
|
||||
? `${bossMetadata.name} Down`
|
||||
: status === 'defeat'
|
||||
? 'Party Defeated'
|
||||
: 'Arena Paused'
|
||||
const overlayEyebrow = status === 'victory'
|
||||
? 'Hunt Complete'
|
||||
: status === 'defeat'
|
||||
? 'Recovery'
|
||||
: 'Paused'
|
||||
const overlayTone = status === 'victory'
|
||||
? 'is-victory'
|
||||
: status === 'defeat'
|
||||
? 'is-defeat'
|
||||
: 'is-paused'
|
||||
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'}`
|
||||
|
||||
return (
|
||||
<main
|
||||
className="game-shell iwt2-arena-shell"
|
||||
data-combat-active={status === 'playing' ? 'true' : 'false'}
|
||||
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
|
||||
>
|
||||
<section className="iwt2-arena-layout">
|
||||
<div className="iwt2-arena-stage">
|
||||
<BossHud boss={arenaState.boss} />
|
||||
<PartyFrames
|
||||
controllerIconStyle={controllerIconStyle}
|
||||
onTarget={setSelectedPartyId}
|
||||
party={arenaState.party}
|
||||
selectedPartyId={selectedPartyId}
|
||||
targetBindings={targetBindings}
|
||||
/>
|
||||
|
||||
<PhaserArena
|
||||
movementRef={movementRef}
|
||||
onStep={onStep}
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
{status === '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"
|
||||
onClick={() => activateOverlayAction('primary')}
|
||||
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||
type="button"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
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>{bossMetadata.icon}</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>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
{status === 'victory' && (
|
||||
<div className="iwt2-result-reward">
|
||||
<span>+125 XP</span>
|
||||
<span>Common carve</span>
|
||||
<span>Log +1</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="iwt2-overlay-actions">
|
||||
<button
|
||||
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={() => 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, B exits</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AbilityBar
|
||||
abilities={abilities}
|
||||
cooldowns={abilityCooldowns}
|
||||
mana={playerMana}
|
||||
onCast={castAbility}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function formatArenaTime(seconds: number): string {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds))
|
||||
const minutes = Math.floor(safeSeconds / 60)
|
||||
const remainder = safeSeconds % 60
|
||||
return `${minutes}:${remainder.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||
return [
|
||||
state.boss.id,
|
||||
Math.ceil(state.boss.health),
|
||||
state.boss.attackPhase,
|
||||
state.hostileAdds.length,
|
||||
state.hazards.length,
|
||||
...state.party.map((member) => [
|
||||
member.id,
|
||||
Math.ceil(member.health),
|
||||
Math.ceil(member.shield),
|
||||
Math.ceil(member.mana),
|
||||
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
|
||||
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
|
||||
].join(':')),
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
|
||||
const living = state.party.filter((member) => member.health > 0)
|
||||
const targets = living.length > 0 ? living : state.party
|
||||
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
|
||||
const nextIndex = (currentIndex + direction + targets.length) % targets.length
|
||||
return targets[nextIndex]?.id ?? currentId
|
||||
}
|
||||
|
||||
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
||||
let changed = false
|
||||
const next: Record<string, number> = {}
|
||||
for (const [id, remaining] of Object.entries(cooldowns)) {
|
||||
const value = Math.max(0, remaining - dt)
|
||||
if (value !== remaining) changed = true
|
||||
if (value > 0) next[id] = value
|
||||
}
|
||||
return changed ? next : cooldowns
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,661 @@
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaInput,
|
||||
Iwt2ArenaState,
|
||||
Iwt2EntityId,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2HostileAddState,
|
||||
Iwt2PartyAiRole,
|
||||
Iwt2PartyEntityId,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2ProjectileEntityState,
|
||||
Iwt2StatusState,
|
||||
} from './types'
|
||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||
import { tickBoss } from './bossAi'
|
||||
import { separateCircles } from './collision'
|
||||
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
||||
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
||||
import { applyPartyDamageInShape } from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
} from './vector'
|
||||
|
||||
const DEFAULT_ARENA_WIDTH = 960
|
||||
const DEFAULT_ARENA_HEIGHT = 540
|
||||
const MAX_DT = 1 / 15
|
||||
const MAX_EVENTS = 80
|
||||
const HEALER_MANA_REGEN_PER_SECOND = 3
|
||||
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
||||
|
||||
type InitialPartyMember = {
|
||||
id: Iwt2PartyEntityId
|
||||
classId: Iwt2PlayerClassId
|
||||
aiRole: Iwt2PartyAiRole
|
||||
x: number
|
||||
y: number
|
||||
preferredOffset: { x: number; y: number }
|
||||
decisionOffset: number
|
||||
}
|
||||
|
||||
const INITIAL_PARTY: InitialPartyMember[] = [
|
||||
{ id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 },
|
||||
{ id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 },
|
||||
{ id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 },
|
||||
{ id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 },
|
||||
{ id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 },
|
||||
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
|
||||
]
|
||||
|
||||
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
time: 0,
|
||||
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||
party: INITIAL_PARTY.map(createPartyMember),
|
||||
projectiles: [],
|
||||
hostileAdds: [],
|
||||
hazards: [],
|
||||
indicators: [],
|
||||
boss: {
|
||||
id: bossId,
|
||||
kind: 'boss',
|
||||
bossId,
|
||||
position: { x: 640, y: 250 },
|
||||
velocity: { x: 0, y: 0 },
|
||||
facing: { x: -1, y: 0 },
|
||||
radius: bossMetadata.radius,
|
||||
health: bossMetadata.maxHealth,
|
||||
maxHealth: bossMetadata.maxHealth,
|
||||
meleeCooldownRemaining: 0.6,
|
||||
chargeCooldownRemaining: initialBossSpecialCooldown(bossId),
|
||||
chargeCount: 0,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
chargeStart: { x: 640, y: 250 },
|
||||
chargeEnd: { x: 640, y: 250 },
|
||||
chargeHitEntityIds: [],
|
||||
slamApplied: false,
|
||||
wallContactSeconds: 0,
|
||||
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 },
|
||||
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId),
|
||||
fireballTarget: { x: 320, y: 250 },
|
||||
birdWaveThresholdsTriggered: [],
|
||||
mechanicLanes: [],
|
||||
mechanicCircles: [],
|
||||
},
|
||||
nextEventId: 1,
|
||||
nextProjectileId: 1,
|
||||
nextAddId: 1,
|
||||
nextHazardId: 1,
|
||||
events: [],
|
||||
}
|
||||
}
|
||||
|
||||
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: separatePartyFromBoss(baseState.party, baseState),
|
||||
}
|
||||
const bossResult = tickBoss(separatedState, step)
|
||||
const projectileResult = advanceProjectiles(
|
||||
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
||||
bossResult.party,
|
||||
bossResult.boss,
|
||||
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
||||
bossResult.hazards ?? separatedState.hazards,
|
||||
bossResult.nextHazardId ?? state.nextHazardId,
|
||||
bossResult.nextProjectileId ?? state.nextProjectileId,
|
||||
separatedState.bounds,
|
||||
separatedState.time,
|
||||
step,
|
||||
)
|
||||
const hazardResult = tickGroundHazards({
|
||||
dt: step,
|
||||
hazards: projectileResult.hazards,
|
||||
party: projectileResult.party,
|
||||
time: separatedState.time,
|
||||
})
|
||||
const damageResult = applyPartyAttacks(
|
||||
hazardResult.party,
|
||||
projectileResult.boss,
|
||||
projectileResult.hostileAdds,
|
||||
separatedState.time,
|
||||
projectileResult.nextProjectileId,
|
||||
)
|
||||
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
|
||||
const finalParty = regeneratePartyMana(hotResult.party, step)
|
||||
const nextEvents = assignEventIds(
|
||||
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
|
||||
state.nextEventId,
|
||||
)
|
||||
return {
|
||||
...separatedState,
|
||||
boss: damageResult.boss,
|
||||
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
||||
party: finalParty,
|
||||
hostileAdds: damageResult.hostileAdds,
|
||||
hazards: hazardResult.hazards,
|
||||
projectiles: [...projectileResult.projectiles, ...damageResult.projectiles],
|
||||
nextProjectileId: damageResult.nextProjectileId,
|
||||
nextAddId: bossResult.nextAddId ?? state.nextAddId,
|
||||
nextHazardId: projectileResult.nextHazardId,
|
||||
nextEventId: state.nextEventId + nextEvents.length,
|
||||
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
|
||||
}
|
||||
}
|
||||
|
||||
function tickPartyHotEffects(
|
||||
party: Iwt2PartyEntityState[],
|
||||
dt: number,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const nextParty = party.map((member) => {
|
||||
if (member.hotEffects.length === 0) return member
|
||||
|
||||
let health = member.health
|
||||
const hotEffects = member.hotEffects.flatMap((effect) => {
|
||||
const remainingSeconds = effect.remainingSeconds - dt
|
||||
if (remainingSeconds <= 0) return []
|
||||
let nextTickInSeconds = effect.nextTickInSeconds - dt
|
||||
if (member.health > 0 && nextTickInSeconds <= 0) {
|
||||
const before = health
|
||||
health = Math.min(member.maxHealth, health + effect.power)
|
||||
const healed = health - before
|
||||
if (healed > 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'partyHealed',
|
||||
sourceId: 'player-healer',
|
||||
targetId: member.id,
|
||||
value: healed,
|
||||
})
|
||||
}
|
||||
nextTickInSeconds += effect.tickIntervalSeconds
|
||||
}
|
||||
return [{
|
||||
...effect,
|
||||
nextTickInSeconds,
|
||||
remainingSeconds,
|
||||
}]
|
||||
})
|
||||
|
||||
return {
|
||||
...member,
|
||||
health,
|
||||
hotEffects,
|
||||
}
|
||||
})
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => {
|
||||
if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member
|
||||
return {
|
||||
...member,
|
||||
mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
|
||||
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
||||
return {
|
||||
id: initial.id,
|
||||
kind: 'party',
|
||||
classId: initial.classId,
|
||||
aiRole: initial.aiRole,
|
||||
position: { x: initial.x, y: initial.y },
|
||||
velocity: { x: 0, y: 0 },
|
||||
facing: { x: 1, y: 0 },
|
||||
radius: metadata.radius,
|
||||
health: metadata.maxHealth,
|
||||
maxHealth: metadata.maxHealth,
|
||||
shield: 0,
|
||||
mana: initial.classId === 'healer' ? 100 : 0,
|
||||
maxMana: initial.classId === 'healer' ? 100 : 0,
|
||||
damageDone: 0,
|
||||
attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35,
|
||||
castSecondsRemaining: 0,
|
||||
attackReady: false,
|
||||
status: createEmptyStatus(),
|
||||
hotEffects: [],
|
||||
preferredOffset: { ...initial.preferredOffset },
|
||||
decisionSecondsRemaining: initial.decisionOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyStatus(): Iwt2StatusState {
|
||||
return {
|
||||
stunnedSeconds: 0,
|
||||
knockedDownSeconds: 0,
|
||||
invulnerableSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function clampInputAxis(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(1, Math.max(-1, value))
|
||||
}
|
||||
|
||||
function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
const position = separateCircles(
|
||||
{ position: member.position, radius: member.radius },
|
||||
{ position: state.boss.position, radius: state.boss.radius },
|
||||
state.bounds,
|
||||
)
|
||||
return { ...member, position }
|
||||
})
|
||||
}
|
||||
|
||||
function advanceProjectiles(
|
||||
projectiles: Iwt2ProjectileEntityState[],
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2ArenaState['boss'],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
hazards: Iwt2GroundHazardState[],
|
||||
nextHazardId: number,
|
||||
nextProjectileId: number,
|
||||
bounds: Iwt2ArenaState['bounds'],
|
||||
time: number,
|
||||
dt: number,
|
||||
): {
|
||||
boss: Iwt2ArenaState['boss']
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
nextProjectileId: number
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let nextBoss = boss
|
||||
let nextParty = party
|
||||
let nextHostileAdds = hostileAdds
|
||||
let nextHazards = hazards
|
||||
let nextHazardIdValue = nextHazardId
|
||||
const nextProjectiles: Iwt2ProjectileEntityState[] = []
|
||||
|
||||
for (const projectile of projectiles) {
|
||||
if (projectile.owner === 'boss') {
|
||||
const result = advanceBossProjectile({
|
||||
bounds,
|
||||
dt,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
party: nextParty,
|
||||
projectile,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
nextHazards = result.hazards
|
||||
nextHazardIdValue = result.nextHazardId
|
||||
events.push(...result.events)
|
||||
if (result.projectile) nextProjectiles.push(result.projectile)
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue
|
||||
const nextPosition = {
|
||||
x: projectile.position.x + projectile.velocity.x * dt,
|
||||
y: projectile.position.y + projectile.velocity.y * dt,
|
||||
}
|
||||
const remainingSeconds = projectile.remainingSeconds - dt
|
||||
const hitAdd = nextHostileAdds.find((add) => (
|
||||
add.health > 0
|
||||
&& distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius
|
||||
))
|
||||
if (remainingSeconds <= 0) continue
|
||||
if (hitAdd) {
|
||||
const damage = Math.min(projectile.damage, hitAdd.health)
|
||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id
|
||||
? { ...add, health: Math.max(0, add.health - damage) }
|
||||
: add)
|
||||
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: hitAdd.id,
|
||||
value: damage,
|
||||
})
|
||||
if (hitAdd.health - damage <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: hitAdd.id,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) {
|
||||
const damage = Math.min(projectile.damage, nextBoss.health)
|
||||
nextBoss = {
|
||||
...nextBoss,
|
||||
health: Math.max(0, nextBoss.health - damage),
|
||||
}
|
||||
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: nextBoss.id,
|
||||
value: damage,
|
||||
})
|
||||
if (nextBoss.health <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: nextBoss.id,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
nextProjectiles.push({
|
||||
...projectile,
|
||||
position: nextPosition,
|
||||
remainingSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
boss: nextBoss,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
nextProjectileId,
|
||||
projectiles: nextProjectiles,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
function advanceBossProjectile({
|
||||
bounds,
|
||||
dt,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
projectile,
|
||||
time,
|
||||
}: {
|
||||
bounds: Iwt2ArenaState['bounds']
|
||||
dt: number
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
projectile: Iwt2ProjectileEntityState
|
||||
time: number
|
||||
}): {
|
||||
party: Iwt2PartyEntityState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
projectile?: Iwt2ProjectileEntityState
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const remainingSeconds = projectile.remainingSeconds - dt
|
||||
if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events }
|
||||
|
||||
let nextParty = party
|
||||
let nextHazards = hazards
|
||||
let nextHazardIdValue = nextHazardId
|
||||
let bounced = false
|
||||
const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt)
|
||||
const canBounce = bounceCooldownSeconds <= 0
|
||||
let velocity = { ...projectile.velocity }
|
||||
let position = {
|
||||
x: projectile.position.x + projectile.velocity.x * dt,
|
||||
y: projectile.position.y + projectile.velocity.y * dt,
|
||||
}
|
||||
|
||||
if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) {
|
||||
velocity = { ...velocity, x: -velocity.x }
|
||||
position = clampVec2ToArena(position, projectile.radius, bounds)
|
||||
bounced = true
|
||||
}
|
||||
if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) {
|
||||
velocity = { ...velocity, y: -velocity.y }
|
||||
position = clampVec2ToArena(position, projectile.radius, bounds)
|
||||
bounced = true
|
||||
}
|
||||
|
||||
const hitMember = canBounce
|
||||
? nextParty.find((member) => (
|
||||
member.health > 0
|
||||
&& distanceVec2(position, member.position) <= projectile.radius + member.radius
|
||||
))
|
||||
: undefined
|
||||
if (hitMember) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: hitMember.position,
|
||||
radius: hitMember.radius + projectile.radius,
|
||||
}, {
|
||||
damage: projectile.damage,
|
||||
sourceId: projectile.sourceId,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
events.push(...result.events)
|
||||
const away = normalizeVec2(subtractVec2(position, hitMember.position))
|
||||
const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1))
|
||||
const direction = away.x === 0 && away.y === 0 ? fallback : away
|
||||
const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y))
|
||||
velocity = scaleVec2(direction, speed)
|
||||
position = clampVec2ToArena({
|
||||
x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2),
|
||||
y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2),
|
||||
}, projectile.radius, bounds)
|
||||
bounced = true
|
||||
}
|
||||
|
||||
if (bounced) {
|
||||
const puddle = addFirePuddle({
|
||||
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
||||
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
position: hitMember?.position ?? position,
|
||||
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
||||
sourceId: projectile.sourceId,
|
||||
time,
|
||||
})
|
||||
nextHazards = puddle.hazards
|
||||
nextHazardIdValue = puddle.nextHazardId
|
||||
}
|
||||
|
||||
const bouncesRemaining = bounced
|
||||
? (projectile.bouncesRemaining ?? 0) - 1
|
||||
: projectile.bouncesRemaining
|
||||
if (bouncesRemaining !== undefined && bouncesRemaining < 0) {
|
||||
return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events }
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
party: nextParty,
|
||||
projectile: {
|
||||
...projectile,
|
||||
bouncesRemaining,
|
||||
bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds,
|
||||
position,
|
||||
remainingSeconds,
|
||||
velocity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function applyPartyAttacks(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2ArenaState['boss'],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
time: number,
|
||||
nextProjectileId: number,
|
||||
): {
|
||||
boss: Iwt2ArenaState['boss']
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
nextProjectileId: number
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||
let nextBoss = boss
|
||||
let nextHostileAdds = hostileAdds
|
||||
let projectileId = nextProjectileId
|
||||
const nextParty = party.map((member) => {
|
||||
const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds)
|
||||
if (!target) return member
|
||||
if (!canPartyMemberHitTarget(member, target.position)) return member
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
if (metadata.projectileSpeed > 0) {
|
||||
if (!member.attackReady) return member
|
||||
const direction = normalizeVec2(subtractVec2(target.position, member.position))
|
||||
projectiles.push({
|
||||
id: `projectile-${projectileId}`,
|
||||
sourceId: member.id,
|
||||
owner: 'party',
|
||||
classId: member.classId,
|
||||
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
|
||||
color: metadata.accentColor,
|
||||
position: {
|
||||
x: member.position.x + direction.x * (member.radius + 4),
|
||||
y: member.position.y + direction.y * (member.radius + 4),
|
||||
},
|
||||
velocity: scaleVec2(direction, metadata.projectileSpeed),
|
||||
radius: member.classId === 'mage' ? 7 : 4,
|
||||
damage: metadata.attackDamage,
|
||||
remainingSeconds: 1.2,
|
||||
})
|
||||
projectileId += 1
|
||||
return {
|
||||
...member,
|
||||
attackCooldownRemaining: metadata.attackCooldown,
|
||||
attackReady: false,
|
||||
}
|
||||
}
|
||||
if (member.attackCooldownRemaining > 0) return member
|
||||
const damage = Math.min(metadata.attackDamage, target.health)
|
||||
if (target.kind === 'boss') {
|
||||
nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) }
|
||||
} else {
|
||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
||||
? { ...add, health: Math.max(0, add.health - damage) }
|
||||
: add)
|
||||
}
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: member.id,
|
||||
targetId: target.id,
|
||||
value: damage,
|
||||
})
|
||||
if (target.health - damage <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: member.id,
|
||||
targetId: target.id,
|
||||
})
|
||||
return {
|
||||
...member,
|
||||
damageDone: member.damageDone + damage,
|
||||
attackCooldownRemaining: metadata.attackCooldown,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...member,
|
||||
damageDone: member.damageDone + damage,
|
||||
attackCooldownRemaining: metadata.attackCooldown,
|
||||
}
|
||||
})
|
||||
return {
|
||||
boss: nextBoss,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
||||
projectiles,
|
||||
nextProjectileId: projectileId,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityAttackTarget(
|
||||
member: Iwt2PartyEntityState,
|
||||
boss: Iwt2ArenaState['boss'],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
|
||||
if (member.health <= 0) return undefined
|
||||
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
||||
if (livingAdds.length > 0) {
|
||||
return livingAdds.reduce((best, add) => (
|
||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||
), livingAdds[0])
|
||||
}
|
||||
return boss.health > 0 ? boss : undefined
|
||||
}
|
||||
|
||||
function addDamageDone(
|
||||
party: Iwt2PartyEntityState[],
|
||||
sourceId: Iwt2EntityId,
|
||||
damage: number,
|
||||
): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => member.id === sourceId
|
||||
? { ...member, damageDone: member.damageDone + damage }
|
||||
: member)
|
||||
}
|
||||
|
||||
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
|
||||
return events.map((event, index) => ({ ...event, id: firstId + index }))
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import {
|
||||
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
|
||||
tickIwt2Arena as tickCoreIwt2Arena,
|
||||
} from './arena'
|
||||
import type {
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaInput,
|
||||
Iwt2ArenaState as Iwt2CoreArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2HostileAddState,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
|
||||
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
|
||||
|
||||
export type Iwt2ArenaEntity = {
|
||||
id: string
|
||||
kind: Iwt2ArenaEntityKind
|
||||
icon: string
|
||||
color: string
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
health: number
|
||||
maxHealth: number
|
||||
stunnedFor: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaTelegraph =
|
||||
| {
|
||||
kind: 'charge'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
active: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'slam'
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type Iwt2ArenaState = Iwt2CoreArenaState & {
|
||||
arena: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
entities: Iwt2ArenaEntity[]
|
||||
telegraphs: Iwt2ArenaTelegraph[]
|
||||
}
|
||||
|
||||
export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState {
|
||||
return decorateArenaState(createCoreIwt2ArenaState(bossId))
|
||||
}
|
||||
|
||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||
return decorateArenaState(tickCoreIwt2Arena(state, input, dt))
|
||||
}
|
||||
|
||||
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
|
||||
return {
|
||||
...state,
|
||||
arena: { ...state.bounds },
|
||||
entities: [
|
||||
...state.party.map(toArenaEntity),
|
||||
...state.hostileAdds.map(toHostileAddArenaEntity),
|
||||
toBossArenaEntity(state.boss),
|
||||
],
|
||||
telegraphs: createTelegraphs(state.indicators),
|
||||
}
|
||||
}
|
||||
|
||||
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
|
||||
return {
|
||||
id: add.id,
|
||||
kind: 'hostileAdd',
|
||||
icon: 'v',
|
||||
color: '#f0b84f',
|
||||
x: add.position.x,
|
||||
y: add.position.y,
|
||||
radius: add.radius,
|
||||
health: add.health,
|
||||
maxHealth: add.maxHealth,
|
||||
stunnedFor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
return {
|
||||
id: member.id,
|
||||
kind: member.aiRole === 'player' ? 'player' : 'party',
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: member.position.x,
|
||||
y: member.position.y,
|
||||
radius: member.radius,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
|
||||
}
|
||||
}
|
||||
|
||||
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
|
||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
return {
|
||||
id: boss.id,
|
||||
kind: 'boss',
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: boss.position.x,
|
||||
y: boss.position.y,
|
||||
radius: boss.radius,
|
||||
health: boss.health,
|
||||
maxHealth: boss.maxHealth,
|
||||
stunnedFor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
|
||||
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
|
||||
if (indicator.kind === 'lane') {
|
||||
return [{
|
||||
active: indicator.phase === 'active',
|
||||
height: indicator.width * 2,
|
||||
kind: 'charge' as const,
|
||||
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
|
||||
x: Math.min(indicator.start.x, indicator.end.x),
|
||||
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
|
||||
}]
|
||||
}
|
||||
if (indicator.kind === 'circle') {
|
||||
return [{
|
||||
active: indicator.phase === 'active',
|
||||
kind: 'slam' as const,
|
||||
radius: indicator.radius,
|
||||
x: indicator.position.x,
|
||||
y: indicator.position.y,
|
||||
}]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Iwt2HealerAbility } from '../content/healerAbilities'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaState,
|
||||
Iwt2EntityId,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
import { createArenaEvent } from './mechanics'
|
||||
|
||||
const HEALER_ID: Iwt2EntityId = 'player-healer'
|
||||
const MAX_EVENTS = 80
|
||||
const DEFAULT_HOT_SECONDS = 8
|
||||
const DEFAULT_HOT_TICK_SECONDS = 1
|
||||
|
||||
export type Iwt2HealerCastResult<TState extends Iwt2ArenaState = Iwt2ArenaState> = {
|
||||
cast: boolean
|
||||
state: TState
|
||||
}
|
||||
|
||||
export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
|
||||
state: TState,
|
||||
ability: Iwt2HealerAbility,
|
||||
selectedTargetId: Iwt2EntityId,
|
||||
): Iwt2HealerCastResult<TState> {
|
||||
if (state.boss.health <= 0) return { cast: false, state }
|
||||
const healer = state.party.find((member) => member.id === HEALER_ID)
|
||||
if (!healer || healer.health <= 0 || healer.mana < ability.manaCost) return { cast: false, state }
|
||||
|
||||
const targetIds = targetIdsForAbility(state.party, ability, selectedTargetId)
|
||||
if (targetIds.length === 0) return { cast: false, state }
|
||||
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let eventId = state.nextEventId
|
||||
const targetIdSet = new Set(targetIds)
|
||||
const nextParty = state.party.map((member) => {
|
||||
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
|
||||
if (!targetIdSet.has(member.id)) return { ...member, mana }
|
||||
|
||||
const nextMember = applyAbilityToMember(member, ability)
|
||||
const healed = Math.max(0, nextMember.health - member.health)
|
||||
if (healed > 0) {
|
||||
events.push(createArenaEvent(eventId, state.time, 'partyHealed', HEALER_ID, member.id, healed))
|
||||
eventId += 1
|
||||
}
|
||||
return { ...nextMember, mana }
|
||||
})
|
||||
|
||||
return {
|
||||
cast: true,
|
||||
state: {
|
||||
...state,
|
||||
party: nextParty,
|
||||
nextEventId: eventId,
|
||||
events: [...state.events, ...events].slice(-MAX_EVENTS),
|
||||
} as TState,
|
||||
}
|
||||
}
|
||||
|
||||
function targetIdsForAbility(
|
||||
party: Iwt2PartyEntityState[],
|
||||
ability: Iwt2HealerAbility,
|
||||
selectedTargetId: Iwt2EntityId,
|
||||
): Iwt2EntityId[] {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) return []
|
||||
const extraTargets = Math.max(0, Math.floor(ability.extraTargets ?? 0))
|
||||
if (ability.kind === 'group') {
|
||||
return [...living]
|
||||
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
||||
.slice(0, 4 + extraTargets)
|
||||
.map((member) => member.id)
|
||||
}
|
||||
const selected = living.find((member) => member.id === selectedTargetId)
|
||||
const primaryId = selected?.id ?? living[0].id
|
||||
if (extraTargets === 0) return [primaryId]
|
||||
const additionalTargets = living
|
||||
.filter((member) => member.id !== primaryId)
|
||||
.sort((a, b) => healthRatio(a) - healthRatio(b))
|
||||
.slice(0, extraTargets)
|
||||
.map((member) => member.id)
|
||||
return [primaryId, ...additionalTargets]
|
||||
}
|
||||
|
||||
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
|
||||
if (ability.kind === 'hot') {
|
||||
return {
|
||||
...healMember(member, ability.power),
|
||||
hotEffects: [
|
||||
...member.hotEffects.filter((effect) => effect.id !== ability.id),
|
||||
{
|
||||
id: ability.id,
|
||||
label: ability.name,
|
||||
power: Math.max(1, Math.round(ability.power * 0.5)),
|
||||
remainingSeconds: DEFAULT_HOT_SECONDS,
|
||||
tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS,
|
||||
nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (ability.kind === 'shield') {
|
||||
return {
|
||||
...member,
|
||||
shield: Math.max(member.shield, ability.power),
|
||||
}
|
||||
}
|
||||
|
||||
if (ability.kind === 'cleanse') {
|
||||
return {
|
||||
...healMember(member, ability.power),
|
||||
status: {
|
||||
...member.status,
|
||||
knockedDownSeconds: 0,
|
||||
stunnedSeconds: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return healMember(member, ability.power)
|
||||
}
|
||||
|
||||
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
|
||||
return {
|
||||
...member,
|
||||
health: Math.min(member.maxHealth, member.health + amount),
|
||||
}
|
||||
}
|
||||
|
||||
function healthRatio(member: Iwt2PartyEntityState) {
|
||||
return member.health / member.maxHealth
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export {
|
||||
createInitialIwt2ArenaState,
|
||||
tickIwt2Arena,
|
||||
} from './arena'
|
||||
|
||||
export {
|
||||
circlesOverlap,
|
||||
circleIntersectsSegment,
|
||||
distanceToSegment,
|
||||
separateCircles,
|
||||
} from './collision'
|
||||
|
||||
export {
|
||||
applyPartyDamageInShape,
|
||||
createArenaEvent,
|
||||
createCircleIndicator,
|
||||
createConeIndicator,
|
||||
createDonutIndicator,
|
||||
createLaneIndicator,
|
||||
createLinearMovementPlan,
|
||||
indicatorPhaseFromAttack,
|
||||
isIndicatorActive,
|
||||
partyMemberIntersectsShape,
|
||||
} from './mechanics'
|
||||
|
||||
export {
|
||||
castIwt2HealerAbility,
|
||||
} from './healing'
|
||||
|
||||
export {
|
||||
addVec2,
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
dotVec2,
|
||||
lengthSqVec2,
|
||||
lengthVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
} from './vector'
|
||||
|
||||
export type {
|
||||
Iwt2ArenaBounds,
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaEventType,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaIndicatorPhase,
|
||||
Iwt2ArenaInput,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossAttackPhase,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2Circle,
|
||||
Iwt2EntityId,
|
||||
Iwt2EntityKind,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2HotEffectState,
|
||||
Iwt2HostileAddAttackPhase,
|
||||
Iwt2HostileAddState,
|
||||
Iwt2PartyAiRole,
|
||||
Iwt2PartyEntityId,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2ProjectileEntityState,
|
||||
Iwt2StatusState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
@@ -0,0 +1,258 @@
|
||||
import { KHEZU_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2BossTickResult } from './bossAi'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2MechanicCircleState,
|
||||
Iwt2PartyEntityState,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createCircleIndicator,
|
||||
createDonutIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
} from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export function tickKhezu(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),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
const target = getBossTarget(party)
|
||||
|
||||
if (boss.health <= 0 || !target) return withKhezuIndicators({ boss, party, events })
|
||||
|
||||
if (boss.attackPhase === 'thunderRingWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyThunderRing(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'thunderRingRecover',
|
||||
phaseSecondsRemaining: 0.45,
|
||||
}
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'lightningStrikeWindup') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const result = applyLightningStrikes(party, boss, state.time + dt)
|
||||
party = result.party
|
||||
events.push(...result.events)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'lightningStrikeRecover',
|
||||
phaseSecondsRemaining: 0.38,
|
||||
}
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'thunderRingRecover' || boss.attackPhase === 'lightningStrikeRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
mechanicCircles: [],
|
||||
phaseSecondsRemaining: 0,
|
||||
}
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.chargeCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'thunderRingWindup',
|
||||
chargeCooldownRemaining: KHEZU_BOSS_METADATA.thunderRingCooldown!,
|
||||
phaseSecondsRemaining: KHEZU_BOSS_METADATA.thunderRingWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'lightningStrikeWindup',
|
||||
fireballCooldownRemaining: KHEZU_BOSS_METADATA.lightningStrikeCooldown!,
|
||||
mechanicCircles: createLightningTargets(party),
|
||||
phaseSecondsRemaining: KHEZU_BOSS_METADATA.lightningStrikeWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withKhezuIndicators({ 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 withKhezuIndicators({ boss, party, events })
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, target.position, KHEZU_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 withKhezuIndicators({ boss, party, events })
|
||||
}
|
||||
|
||||
function createLightningTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
|
||||
return [...party]
|
||||
.filter((member) => member.health > 0)
|
||||
.sort((a, b) => (a.health / a.maxHealth) - (b.health / b.maxHealth))
|
||||
.slice(0, KHEZU_BOSS_METADATA.lightningStrikeCount!)
|
||||
.map((member, index) => ({
|
||||
id: `lightning-${index}`,
|
||||
position: { ...member.position },
|
||||
radius: KHEZU_BOSS_METADATA.lightningStrikeRadius!,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyThunderRing(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'donut',
|
||||
position: boss.position,
|
||||
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
|
||||
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
|
||||
}, {
|
||||
damage: KHEZU_BOSS_METADATA.thunderRingDamage!,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: KHEZU_BOSS_METADATA.thunderRingStunSeconds!,
|
||||
time,
|
||||
})
|
||||
return { party: result.party, events: result.events }
|
||||
}
|
||||
|
||||
function applyLightningStrikes(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
let nextParty = party
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
}, {
|
||||
damage: KHEZU_BOSS_METADATA.lightningStrikeDamage!,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: boss.id,
|
||||
stunSeconds: KHEZU_BOSS_METADATA.lightningStrikeStunSeconds!,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
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) > KHEZU_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: KHEZU_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: KHEZU_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: KHEZU_BOSS_METADATA.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withKhezuIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createKhezuIndicators(result.boss),
|
||||
}
|
||||
}
|
||||
|
||||
function createKhezuIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
if (boss.attackPhase === 'thunderRingWindup' || boss.attackPhase === 'thunderRingRecover') {
|
||||
indicators.push(createDonutIndicator({
|
||||
color: '#77d9ff',
|
||||
id: `${boss.id}:thunder-ring`,
|
||||
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
|
||||
mechanicId: 'khezu-thunder-ring',
|
||||
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'thunderRingWindup',
|
||||
boss.attackPhase === 'thunderRingRecover',
|
||||
),
|
||||
position: boss.position,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
if (boss.attackPhase === 'lightningStrikeWindup' || boss.attackPhase === 'lightningStrikeRecover') {
|
||||
for (const circle of boss.mechanicCircles) {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#9be8ff',
|
||||
id: `${boss.id}:${circle.id}`,
|
||||
mechanicId: 'khezu-lightning-strike',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
boss.attackPhase === 'lightningStrikeWindup',
|
||||
boss.attackPhase === 'lightningStrikeRecover',
|
||||
),
|
||||
position: circle.position,
|
||||
radius: circle.radius,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type {
|
||||
Iwt2ArenaBounds,
|
||||
Iwt2ArenaCircleIndicator,
|
||||
Iwt2ArenaConeIndicator,
|
||||
Iwt2ArenaDonutIndicator,
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaEventType,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaIndicatorPhase,
|
||||
Iwt2ArenaLaneIndicator,
|
||||
Iwt2EntityId,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import { circleIntersectsSegment } from './collision'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
} from './vector'
|
||||
|
||||
export type Iwt2LinearMovementPlan = {
|
||||
direction: Iwt2Vec2
|
||||
end: Iwt2Vec2
|
||||
}
|
||||
|
||||
export type Iwt2PartyDamageEffect = {
|
||||
sourceId: Iwt2EntityId
|
||||
time: number
|
||||
damage: number
|
||||
damageEventType?: Iwt2ArenaEventType
|
||||
stunSeconds?: number
|
||||
knockdownSeconds?: number
|
||||
statusEventType?: Iwt2ArenaEventType
|
||||
excludedEntityIds?: Iwt2EntityId[]
|
||||
}
|
||||
|
||||
export type Iwt2LaneHitShape = {
|
||||
kind: 'lane'
|
||||
start: Iwt2Vec2
|
||||
end: Iwt2Vec2
|
||||
width: number
|
||||
}
|
||||
|
||||
export type Iwt2CircleHitShape = {
|
||||
kind: 'circle'
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type Iwt2DonutHitShape = {
|
||||
kind: 'donut'
|
||||
position: Iwt2Vec2
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
}
|
||||
|
||||
export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape | Iwt2DonutHitShape
|
||||
|
||||
export function createArenaEvent(
|
||||
id: number,
|
||||
time: number,
|
||||
type: Iwt2ArenaEventType,
|
||||
sourceId: Iwt2EntityId,
|
||||
targetId?: Iwt2EntityId,
|
||||
value?: number,
|
||||
): Iwt2ArenaEvent {
|
||||
return { id, time, type, sourceId, targetId, value }
|
||||
}
|
||||
|
||||
export function createLinearMovementPlan({
|
||||
bounds,
|
||||
from,
|
||||
length,
|
||||
radius,
|
||||
target,
|
||||
}: {
|
||||
bounds: Iwt2ArenaBounds
|
||||
from: Iwt2Vec2
|
||||
length: number
|
||||
radius: number
|
||||
target: Iwt2Vec2
|
||||
}): Iwt2LinearMovementPlan {
|
||||
const direction = normalizeVec2(subtractVec2(target, from))
|
||||
const rawEnd = addVec2(from, scaleVec2(direction, length))
|
||||
return {
|
||||
direction,
|
||||
end: clampVec2ToArena(rawEnd, radius, bounds),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPartyDamageInShape(
|
||||
party: Iwt2PartyEntityState[],
|
||||
shape: Iwt2HitShape,
|
||||
effect: Iwt2PartyDamageEffect,
|
||||
): {
|
||||
party: Iwt2PartyEntityState[]
|
||||
hitEntityIds: Iwt2EntityId[]
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const excluded = new Set(effect.excludedEntityIds ?? [])
|
||||
const hitEntityIds: Iwt2EntityId[] = []
|
||||
const stunSeconds = effect.stunSeconds ?? 0
|
||||
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
|
||||
const nextParty = party.map((member) => {
|
||||
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
|
||||
return member
|
||||
}
|
||||
|
||||
hitEntityIds.push(member.id)
|
||||
const shieldAbsorbed = Math.min(member.shield, effect.damage)
|
||||
const healthDamage = effect.damage - shieldAbsorbed
|
||||
const nextShield = member.shield - shieldAbsorbed
|
||||
const nextHealth = Math.max(0, member.health - healthDamage)
|
||||
events.push(createArenaEvent(
|
||||
0,
|
||||
effect.time,
|
||||
effect.damageEventType ?? 'partyDamaged',
|
||||
effect.sourceId,
|
||||
member.id,
|
||||
effect.damage,
|
||||
))
|
||||
if (stunSeconds > 0 || knockdownSeconds > 0) {
|
||||
events.push(createArenaEvent(
|
||||
0,
|
||||
effect.time,
|
||||
effect.statusEventType ?? 'partyStunned',
|
||||
effect.sourceId,
|
||||
member.id,
|
||||
Math.max(stunSeconds, knockdownSeconds),
|
||||
))
|
||||
}
|
||||
if (member.health > 0 && nextHealth <= 0) {
|
||||
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
|
||||
}
|
||||
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
status: {
|
||||
...member.status,
|
||||
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
|
||||
knockedDownSeconds: Math.max(member.status.knockedDownSeconds, knockdownSeconds),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { party: nextParty, hitEntityIds, events }
|
||||
}
|
||||
|
||||
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
|
||||
if (shape.kind === 'lane') {
|
||||
return circleIntersectsSegment(
|
||||
{ position: member.position, radius: member.radius },
|
||||
shape.start,
|
||||
shape.end,
|
||||
shape.width,
|
||||
)
|
||||
}
|
||||
const distance = distanceVec2(member.position, shape.position)
|
||||
if (shape.kind === 'circle') return distance <= shape.radius + member.radius
|
||||
return distance <= shape.outerRadius + member.radius && distance >= Math.max(0, shape.innerRadius - member.radius)
|
||||
}
|
||||
|
||||
export function createLaneIndicator({
|
||||
color,
|
||||
end,
|
||||
id,
|
||||
mechanicId,
|
||||
phase,
|
||||
sourceId,
|
||||
start,
|
||||
width,
|
||||
}: {
|
||||
color: string
|
||||
end: Iwt2Vec2
|
||||
id: string
|
||||
mechanicId: string
|
||||
phase: Iwt2ArenaIndicatorPhase
|
||||
sourceId: Iwt2EntityId
|
||||
start: Iwt2Vec2
|
||||
width: number
|
||||
}): Iwt2ArenaLaneIndicator {
|
||||
return {
|
||||
color,
|
||||
end: { ...end },
|
||||
fillAlpha: phase === 'active' ? 0.18 : 0.11,
|
||||
id,
|
||||
kind: 'lane',
|
||||
lineAlpha: phase === 'active' ? 0.8 : 0.45,
|
||||
mechanicId,
|
||||
phase,
|
||||
sourceId,
|
||||
start: { ...start },
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
export function createCircleIndicator({
|
||||
color,
|
||||
id,
|
||||
mechanicId,
|
||||
phase,
|
||||
position,
|
||||
radius,
|
||||
sourceId,
|
||||
}: {
|
||||
color: string
|
||||
id: string
|
||||
mechanicId: string
|
||||
phase: Iwt2ArenaIndicatorPhase
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
sourceId: Iwt2EntityId
|
||||
}): Iwt2ArenaCircleIndicator {
|
||||
return {
|
||||
color,
|
||||
fillAlpha: phase === 'active' ? 0.18 : 0.12,
|
||||
id,
|
||||
kind: 'circle',
|
||||
lineAlpha: phase === 'active' ? 0.75 : 0.55,
|
||||
mechanicId,
|
||||
phase,
|
||||
position: { ...position },
|
||||
radius,
|
||||
sourceId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createConeIndicator({
|
||||
angleRadians,
|
||||
color,
|
||||
direction,
|
||||
id,
|
||||
mechanicId,
|
||||
origin,
|
||||
phase,
|
||||
range,
|
||||
sourceId,
|
||||
}: {
|
||||
angleRadians: number
|
||||
color: string
|
||||
direction: Iwt2Vec2
|
||||
id: string
|
||||
mechanicId: string
|
||||
origin: Iwt2Vec2
|
||||
phase: Iwt2ArenaIndicatorPhase
|
||||
range: number
|
||||
sourceId: Iwt2EntityId
|
||||
}): Iwt2ArenaConeIndicator {
|
||||
return {
|
||||
angleRadians,
|
||||
color,
|
||||
direction: normalizeVec2(direction),
|
||||
fillAlpha: phase === 'active' ? 0.18 : 0.1,
|
||||
id,
|
||||
kind: 'cone',
|
||||
lineAlpha: phase === 'active' ? 0.72 : 0.48,
|
||||
mechanicId,
|
||||
origin: { ...origin },
|
||||
phase,
|
||||
range,
|
||||
sourceId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDonutIndicator({
|
||||
color,
|
||||
id,
|
||||
innerRadius,
|
||||
mechanicId,
|
||||
outerRadius,
|
||||
phase,
|
||||
position,
|
||||
sourceId,
|
||||
}: {
|
||||
color: string
|
||||
id: string
|
||||
innerRadius: number
|
||||
mechanicId: string
|
||||
outerRadius: number
|
||||
phase: Iwt2ArenaIndicatorPhase
|
||||
position: Iwt2Vec2
|
||||
sourceId: Iwt2EntityId
|
||||
}): Iwt2ArenaDonutIndicator {
|
||||
return {
|
||||
color,
|
||||
fillAlpha: phase === 'active' ? 0.14 : 0.08,
|
||||
id,
|
||||
innerRadius,
|
||||
kind: 'donut',
|
||||
lineAlpha: phase === 'active' ? 0.76 : 0.5,
|
||||
mechanicId,
|
||||
outerRadius,
|
||||
phase,
|
||||
position: { ...position },
|
||||
sourceId,
|
||||
}
|
||||
}
|
||||
|
||||
export function indicatorPhaseFromAttack(
|
||||
windup: boolean,
|
||||
active: boolean,
|
||||
): Iwt2ArenaIndicatorPhase {
|
||||
if (active) return 'active'
|
||||
if (windup) return 'windup'
|
||||
return 'recover'
|
||||
}
|
||||
|
||||
export function isIndicatorActive(indicator: Iwt2ArenaIndicator): boolean {
|
||||
return indicator.phase === 'active'
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
|
||||
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
||||
import {
|
||||
addVec2,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
dotVec2,
|
||||
lengthSqVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
const CENTER_LEASH_BOSS_IDS = new Set(['great-jaggi', 'khezu'])
|
||||
const CENTER_LEASH_START_DISTANCE = 230
|
||||
const CENTER_LEASH_WALL_MARGIN = 96
|
||||
const CENTER_LEASH_EXTRA_DISTANCE = 36
|
||||
|
||||
export function tickPartyMember(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
inputMove: Iwt2Vec2,
|
||||
dt: number,
|
||||
): Iwt2PartyEntityState {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
const status = {
|
||||
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
|
||||
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
|
||||
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
|
||||
}
|
||||
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
|
||||
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
|
||||
if (member.health <= 0) {
|
||||
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
|
||||
}
|
||||
if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) {
|
||||
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
|
||||
}
|
||||
|
||||
if (member.aiRole === 'player') {
|
||||
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
|
||||
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed)
|
||||
const position = clampVec2ToArena(
|
||||
{
|
||||
x: member.position.x + velocity.x * dt,
|
||||
y: member.position.y + velocity.y * dt,
|
||||
},
|
||||
member.radius,
|
||||
state.bounds,
|
||||
)
|
||||
return {
|
||||
...member,
|
||||
position,
|
||||
velocity,
|
||||
facing: withFallbackFacing(velocity, member.facing),
|
||||
attackCooldownRemaining,
|
||||
castSecondsRemaining: 0,
|
||||
attackReady: false,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
const dangerDestination = getDangerAvoidancePosition(member, state)
|
||||
const attackTarget = getPriorityAttackTarget(member, state)
|
||||
const canCast = member.aiRole === 'ranged'
|
||||
&& attackCooldownRemaining <= 0
|
||||
&& !!attackTarget
|
||||
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position)
|
||||
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
||||
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
||||
? castSecondsRemaining
|
||||
: metadata.castTime
|
||||
return {
|
||||
...member,
|
||||
velocity: { x: 0, y: 0 },
|
||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing),
|
||||
attackCooldownRemaining,
|
||||
castSecondsRemaining: nextCastSecondsRemaining,
|
||||
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
||||
const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining)
|
||||
const maxDistance = metadata.moveSpeed * dt
|
||||
const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
||||
const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
||||
return {
|
||||
...member,
|
||||
position,
|
||||
velocity,
|
||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing),
|
||||
attackCooldownRemaining,
|
||||
castSecondsRemaining: 0,
|
||||
attackReady: false,
|
||||
status,
|
||||
decisionSecondsRemaining: decisionSecondsRemaining <= 0
|
||||
? nextDecisionInterval(member)
|
||||
: decisionSecondsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean {
|
||||
if (member.health <= 0) return false
|
||||
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
if (metadata.attackDamage <= 0) return false
|
||||
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius
|
||||
}
|
||||
|
||||
function getPartyDesiredPosition(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
decisionSecondsRemaining: number,
|
||||
): Iwt2Vec2 {
|
||||
const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 }
|
||||
const attackTarget = getPriorityAttackTarget(member, state)
|
||||
const anchor = attackTarget?.position ?? state.boss.position
|
||||
const centerLeash = getTankCenterLeashPosition(member, state)
|
||||
if (centerLeash) return centerLeash
|
||||
return {
|
||||
x: anchor.x + member.preferredOffset.x + drift.x,
|
||||
y: anchor.y + member.preferredOffset.y + drift.y,
|
||||
}
|
||||
}
|
||||
|
||||
function getTankCenterLeashPosition(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
): Iwt2Vec2 | null {
|
||||
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(state.boss.bossId)) return null
|
||||
|
||||
const center = arenaCenter(state)
|
||||
const bossCenterDistance = distanceVec2(state.boss.position, center)
|
||||
const nearWall = isBossNearWall(state)
|
||||
if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null
|
||||
|
||||
const bossMetadata = IWT2_BOSS_METADATA[state.boss.bossId]
|
||||
const towardCenter = withFallbackFacing(subtractVec2(center, state.boss.position), {
|
||||
x: state.boss.position.x < center.x ? 1 : -1,
|
||||
y: state.boss.position.y < center.y ? 0.35 : -0.35,
|
||||
})
|
||||
const leashDistance = bossMetadata.meleeRange + state.boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
|
||||
return clampVec2ToArena(
|
||||
addVec2(state.boss.position, scaleVec2(towardCenter, leashDistance)),
|
||||
member.radius,
|
||||
state.bounds,
|
||||
)
|
||||
}
|
||||
|
||||
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function isBossNearWall(state: Iwt2ArenaState): boolean {
|
||||
const margin = state.boss.radius + CENTER_LEASH_WALL_MARGIN
|
||||
return (
|
||||
state.boss.position.x <= margin
|
||||
|| state.boss.position.x >= state.bounds.width - margin
|
||||
|| state.boss.position.y <= margin
|
||||
|| state.boss.position.y >= state.bounds.height - margin
|
||||
)
|
||||
}
|
||||
|
||||
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
const boss = state.boss
|
||||
const hazardEscape = getHazardAvoidancePosition(member, state)
|
||||
if (hazardEscape) {
|
||||
return hazardEscape
|
||||
}
|
||||
|
||||
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
||||
const distance = distanceVec2(member.position, boss.position)
|
||||
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
||||
if (distance < dangerRadius) {
|
||||
const away = normalizeVec2(subtractVec2(member.position, boss.position))
|
||||
return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds)
|
||||
}
|
||||
}
|
||||
|
||||
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
|
||||
const danger = chargeDanger(member.position, state)
|
||||
if (danger.inside || danger.ahead) {
|
||||
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
|
||||
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
|
||||
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
|
||||
return clampVec2ToArena(escape, member.radius, state.bounds)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
let escape = { x: 0, y: 0 }
|
||||
let maxNeededDistance = 0
|
||||
|
||||
for (const hazard of state.hazards) {
|
||||
const offset = subtractVec2(member.position, hazard.position)
|
||||
const distance = distanceVec2(member.position, hazard.position)
|
||||
const dangerRadius = hazard.radius + member.radius + 46
|
||||
if (distance >= dangerRadius) continue
|
||||
|
||||
const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), {
|
||||
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
||||
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
|
||||
})
|
||||
const away = distance > 0.001 ? normalizeVec2(offset) : fallback
|
||||
const urgency = dangerRadius - distance
|
||||
escape = addVec2(escape, scaleVec2(away, urgency))
|
||||
maxNeededDistance = Math.max(maxNeededDistance, urgency)
|
||||
}
|
||||
|
||||
if (maxNeededDistance <= 0) return null
|
||||
|
||||
const direction = withFallbackFacing(escape, {
|
||||
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
||||
y: 0,
|
||||
})
|
||||
return clampVec2ToArena(
|
||||
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)),
|
||||
member.radius,
|
||||
state.bounds,
|
||||
)
|
||||
}
|
||||
|
||||
function getPriorityAttackTarget(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
|
||||
if (member.health <= 0) return undefined
|
||||
const livingAdds = state.hostileAdds.filter((add) => add.health > 0)
|
||||
if (livingAdds.length > 0) {
|
||||
return livingAdds.reduce((best, add) => (
|
||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||
), livingAdds[0])
|
||||
}
|
||||
return state.boss.health > 0 ? state.boss : undefined
|
||||
}
|
||||
|
||||
function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) {
|
||||
const boss = state.boss
|
||||
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
|
||||
const lengthSq = Math.max(1, lengthSqVec2(segment))
|
||||
const direction = normalizeVec2(segment)
|
||||
const offset = subtractVec2(position, boss.chargeStart)
|
||||
const along = dotVec2(offset, segment) / lengthSq
|
||||
const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along))))
|
||||
const distance = distanceVec2(position, closest)
|
||||
const width = boss.radius + 22
|
||||
return {
|
||||
direction,
|
||||
inside: along >= -0.08 && along <= 1.08 && distance <= width,
|
||||
ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38,
|
||||
}
|
||||
}
|
||||
|
||||
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
|
||||
const seed = member.id.length * 17
|
||||
return {
|
||||
x: Math.sin(time * 0.7 + seed) * 16,
|
||||
y: Math.cos(time * 0.9 + seed) * 14,
|
||||
}
|
||||
}
|
||||
|
||||
function nextDecisionInterval(member: Iwt2PartyEntityState): number {
|
||||
if (member.aiRole === 'tank') return 0.34
|
||||
if (member.aiRole === 'ranged') return 0.48
|
||||
if (member.aiRole === 'flanker') return 0.28
|
||||
return 0.38
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { Iwt2BossId } from '../content/bosses'
|
||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||
|
||||
export type Iwt2Vec2 = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaBounds = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type Iwt2PartyEntityId =
|
||||
| 'player-healer'
|
||||
| 'paladin-tank'
|
||||
| 'ranger'
|
||||
| 'mage'
|
||||
| 'rogue'
|
||||
| 'warrior'
|
||||
|
||||
export type Iwt2BossEntityId = Iwt2BossId
|
||||
|
||||
export type Iwt2EntityId = Iwt2PartyEntityId | Iwt2BossEntityId | string
|
||||
|
||||
export type Iwt2EntityKind = 'party' | 'boss' | 'hostileAdd'
|
||||
|
||||
export type Iwt2PartyAiRole = 'player' | 'tank' | 'ranged' | 'flanker' | 'melee'
|
||||
|
||||
export type Iwt2StatusState = {
|
||||
stunnedSeconds: number
|
||||
knockedDownSeconds: number
|
||||
invulnerableSeconds: number
|
||||
}
|
||||
|
||||
export type Iwt2HotEffectState = {
|
||||
id: string
|
||||
label: string
|
||||
power: number
|
||||
remainingSeconds: number
|
||||
tickIntervalSeconds: number
|
||||
nextTickInSeconds: number
|
||||
}
|
||||
|
||||
export type Iwt2PartyEntityState = {
|
||||
id: Iwt2PartyEntityId
|
||||
kind: 'party'
|
||||
classId: Iwt2PlayerClassId
|
||||
aiRole: Iwt2PartyAiRole
|
||||
position: Iwt2Vec2
|
||||
velocity: Iwt2Vec2
|
||||
facing: Iwt2Vec2
|
||||
radius: number
|
||||
health: number
|
||||
maxHealth: number
|
||||
shield: number
|
||||
mana: number
|
||||
maxMana: number
|
||||
damageDone: number
|
||||
attackCooldownRemaining: number
|
||||
castSecondsRemaining: number
|
||||
attackReady: boolean
|
||||
status: Iwt2StatusState
|
||||
hotEffects: Iwt2HotEffectState[]
|
||||
preferredOffset: Iwt2Vec2
|
||||
decisionSecondsRemaining: number
|
||||
}
|
||||
|
||||
export type Iwt2ProjectileEntityState = {
|
||||
id: string
|
||||
sourceId: Iwt2EntityId
|
||||
owner: 'party' | 'boss'
|
||||
classId?: Iwt2PlayerClassId
|
||||
projectileKind: 'arrow' | 'fireball' | 'magic'
|
||||
color: string
|
||||
position: Iwt2Vec2
|
||||
velocity: Iwt2Vec2
|
||||
radius: number
|
||||
damage: number
|
||||
remainingSeconds: number
|
||||
bouncesRemaining?: number
|
||||
bounceCooldownSeconds?: number
|
||||
}
|
||||
|
||||
export type Iwt2BossAttackPhase =
|
||||
| 'idle'
|
||||
| 'chargeWindup'
|
||||
| 'charging'
|
||||
| 'chargeRecover'
|
||||
| 'slamWindup'
|
||||
| 'slamRecover'
|
||||
| 'relocating'
|
||||
| 'fireballWindup'
|
||||
| 'fireballRecover'
|
||||
| 'birdSummonWindup'
|
||||
| 'birdSummonRecover'
|
||||
| 'packHowlWindup'
|
||||
| 'packHowlRecover'
|
||||
| 'thunderRingWindup'
|
||||
| 'thunderRingRecover'
|
||||
| 'lightningStrikeWindup'
|
||||
| 'lightningStrikeRecover'
|
||||
|
||||
export type Iwt2HostileAddAttackPhase =
|
||||
| 'idle'
|
||||
| 'flightWindup'
|
||||
| 'flying'
|
||||
| 'flightRecover'
|
||||
|
||||
export type Iwt2BossEntityState = {
|
||||
id: Iwt2BossEntityId
|
||||
kind: 'boss'
|
||||
bossId: Iwt2BossId
|
||||
position: Iwt2Vec2
|
||||
velocity: Iwt2Vec2
|
||||
facing: Iwt2Vec2
|
||||
radius: number
|
||||
health: number
|
||||
maxHealth: number
|
||||
meleeCooldownRemaining: number
|
||||
chargeCooldownRemaining: number
|
||||
chargeCount: number
|
||||
attackPhase: Iwt2BossAttackPhase
|
||||
phaseSecondsRemaining: number
|
||||
chargeStart: Iwt2Vec2
|
||||
chargeEnd: Iwt2Vec2
|
||||
chargeHitEntityIds: Iwt2EntityId[]
|
||||
slamApplied: boolean
|
||||
wallContactSeconds: number
|
||||
relocateTarget: Iwt2Vec2
|
||||
fireballCooldownRemaining: number
|
||||
fireballTarget: Iwt2Vec2
|
||||
birdWaveThresholdsTriggered: number[]
|
||||
mechanicLanes: Iwt2MechanicLaneState[]
|
||||
mechanicCircles: Iwt2MechanicCircleState[]
|
||||
}
|
||||
|
||||
export type Iwt2MechanicLaneState = {
|
||||
id: string
|
||||
start: Iwt2Vec2
|
||||
end: Iwt2Vec2
|
||||
width: number
|
||||
}
|
||||
|
||||
export type Iwt2MechanicCircleState = {
|
||||
id: string
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type Iwt2HostileAddState = {
|
||||
id: string
|
||||
kind: 'hostileAdd'
|
||||
addKind: 'yian-bird'
|
||||
sourceBossId: Iwt2BossId
|
||||
position: Iwt2Vec2
|
||||
velocity: Iwt2Vec2
|
||||
facing: Iwt2Vec2
|
||||
radius: number
|
||||
health: number
|
||||
maxHealth: number
|
||||
damageDone: number
|
||||
attackCooldownRemaining: number
|
||||
flightCooldownRemaining: number
|
||||
attackPhase: Iwt2HostileAddAttackPhase
|
||||
phaseSecondsRemaining: number
|
||||
flightStart: Iwt2Vec2
|
||||
flightEnd: Iwt2Vec2
|
||||
flightHitEntityIds: Iwt2EntityId[]
|
||||
}
|
||||
|
||||
export type Iwt2GroundHazardState = {
|
||||
id: string
|
||||
kind: 'groundHazard'
|
||||
hazardKind: 'firePuddle'
|
||||
sourceId: Iwt2EntityId
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
damage: number
|
||||
remainingSeconds: number
|
||||
fadeSeconds: number
|
||||
nextDamageAt: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaEventType =
|
||||
| 'bossDamaged'
|
||||
| 'partyHealed'
|
||||
| 'partyDamaged'
|
||||
| 'partyStunned'
|
||||
| 'bossChargeStart'
|
||||
| 'bossChargeHit'
|
||||
| 'bossSlam'
|
||||
| 'entityDefeated'
|
||||
|
||||
export type Iwt2ArenaEvent = {
|
||||
id: number
|
||||
time: number
|
||||
type: Iwt2ArenaEventType
|
||||
sourceId: Iwt2EntityId
|
||||
targetId?: Iwt2EntityId
|
||||
value?: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
|
||||
|
||||
export type Iwt2ArenaIndicatorBase = {
|
||||
id: string
|
||||
mechanicId: string
|
||||
sourceId: Iwt2EntityId
|
||||
phase: Iwt2ArenaIndicatorPhase
|
||||
color: string
|
||||
fillAlpha?: number
|
||||
lineAlpha?: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaLaneIndicator = Iwt2ArenaIndicatorBase & {
|
||||
kind: 'lane'
|
||||
start: Iwt2Vec2
|
||||
end: Iwt2Vec2
|
||||
width: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaCircleIndicator = Iwt2ArenaIndicatorBase & {
|
||||
kind: 'circle'
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaConeIndicator = Iwt2ArenaIndicatorBase & {
|
||||
kind: 'cone'
|
||||
origin: Iwt2Vec2
|
||||
direction: Iwt2Vec2
|
||||
range: number
|
||||
angleRadians: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & {
|
||||
kind: 'donut'
|
||||
position: Iwt2Vec2
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
}
|
||||
|
||||
export type Iwt2ArenaIndicator =
|
||||
| Iwt2ArenaLaneIndicator
|
||||
| Iwt2ArenaCircleIndicator
|
||||
| Iwt2ArenaConeIndicator
|
||||
| Iwt2ArenaDonutIndicator
|
||||
|
||||
export type Iwt2ArenaState = {
|
||||
schemaVersion: 1
|
||||
time: number
|
||||
bounds: Iwt2ArenaBounds
|
||||
party: Iwt2PartyEntityState[]
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
boss: Iwt2BossEntityState
|
||||
indicators: Iwt2ArenaIndicator[]
|
||||
nextEventId: number
|
||||
nextProjectileId: number
|
||||
nextAddId: number
|
||||
nextHazardId: number
|
||||
events: Iwt2ArenaEvent[]
|
||||
}
|
||||
|
||||
export type Iwt2ArenaInput = {
|
||||
moveX: number
|
||||
moveY: number
|
||||
}
|
||||
|
||||
export type Iwt2Circle = {
|
||||
position: Iwt2Vec2
|
||||
radius: number
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Iwt2ArenaBounds, Iwt2Vec2 } from './types'
|
||||
|
||||
export const IWT2_ZERO_VECTOR: Iwt2Vec2 = { x: 0, y: 0 }
|
||||
|
||||
export function addVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
|
||||
return { x: a.x + b.x, y: a.y + b.y }
|
||||
}
|
||||
|
||||
export function subtractVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 {
|
||||
return { x: a.x - b.x, y: a.y - b.y }
|
||||
}
|
||||
|
||||
export function scaleVec2(vector: Iwt2Vec2, scale: number): Iwt2Vec2 {
|
||||
return { x: vector.x * scale, y: vector.y * scale }
|
||||
}
|
||||
|
||||
export function dotVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
|
||||
return a.x * b.x + a.y * b.y
|
||||
}
|
||||
|
||||
export function lengthSqVec2(vector: Iwt2Vec2): number {
|
||||
return dotVec2(vector, vector)
|
||||
}
|
||||
|
||||
export function lengthVec2(vector: Iwt2Vec2): number {
|
||||
return Math.sqrt(lengthSqVec2(vector))
|
||||
}
|
||||
|
||||
export function distanceVec2(a: Iwt2Vec2, b: Iwt2Vec2): number {
|
||||
return lengthVec2(subtractVec2(a, b))
|
||||
}
|
||||
|
||||
export function normalizeVec2(vector: Iwt2Vec2): Iwt2Vec2 {
|
||||
const length = lengthVec2(vector)
|
||||
if (length <= 0.0001) return { ...IWT2_ZERO_VECTOR }
|
||||
return { x: vector.x / length, y: vector.y / length }
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function clampVec2ToArena(position: Iwt2Vec2, radius: number, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
|
||||
return {
|
||||
x: clamp(position.x, radius, bounds.width - radius),
|
||||
y: clamp(position.y, radius, bounds.height - radius),
|
||||
}
|
||||
}
|
||||
|
||||
export function moveToward(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
|
||||
const offset = subtractVec2(target, current)
|
||||
const distance = lengthVec2(offset)
|
||||
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
|
||||
return addVec2(current, scaleVec2(offset, maxDistance / distance))
|
||||
}
|
||||
|
||||
export function withFallbackFacing(nextFacing: Iwt2Vec2, fallback: Iwt2Vec2): Iwt2Vec2 {
|
||||
if (lengthSqVec2(nextFacing) <= 0.0001) return { ...fallback }
|
||||
return normalizeVec2(nextFacing)
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
import { YIAN_KUT_KU_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2HostileAddState,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2ProjectileEntityState,
|
||||
Iwt2Vec2,
|
||||
} from './types'
|
||||
import {
|
||||
applyPartyDamageInShape,
|
||||
createCircleIndicator,
|
||||
createLaneIndicator,
|
||||
indicatorPhaseFromAttack,
|
||||
partyMemberIntersectsShape,
|
||||
} from './mechanics'
|
||||
import { addFirePuddle } from './hazards'
|
||||
import {
|
||||
clamp,
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
moveToward,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
withFallbackFacing,
|
||||
} from './vector'
|
||||
|
||||
export type Iwt2YianTickResult = {
|
||||
boss: Iwt2BossEntityState
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
events: Iwt2ArenaEvent[]
|
||||
indicators: Iwt2ArenaIndicator[]
|
||||
nextAddId: number
|
||||
nextHazardId: number
|
||||
nextProjectileId: number
|
||||
}
|
||||
|
||||
const BIRD_COUNT = 3
|
||||
const BIRD_MELEE_RANGE = 24
|
||||
const BIRD_MELEE_COOLDOWN = 1.15
|
||||
const BIRD_FLIGHT_DAMAGE = 10
|
||||
const YIAN_FIREBALL_BOUNCES = 8
|
||||
const YIAN_SAFE_WALL_MARGIN = 118
|
||||
const YIAN_CENTER_CAST_DISTANCE = 36
|
||||
const YIAN_CENTER_CHARGE_SPEED = 430
|
||||
|
||||
export function tickYianKutKu(state: Iwt2ArenaState, dt: number): Iwt2YianTickResult {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||
let party = state.party
|
||||
let hazards = state.hazards
|
||||
let nextAddId = state.nextAddId
|
||||
let nextHazardId = state.nextHazardId
|
||||
let nextProjectileId = state.nextProjectileId
|
||||
let boss = {
|
||||
...state.boss,
|
||||
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
|
||||
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
const birdResult = tickYianBirds({
|
||||
dt,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time: state.time + dt,
|
||||
})
|
||||
party = birdResult.party
|
||||
hazards = birdResult.hazards
|
||||
nextHazardId = birdResult.nextHazardId
|
||||
events.push(...birdResult.events)
|
||||
let hostileAdds = birdResult.hostileAdds
|
||||
|
||||
const target = getBossTarget(party)
|
||||
if (boss.health <= 0 || !target) {
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'relocating') {
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, boss.relocateTarget, YIAN_CENTER_CHARGE_SPEED * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
boss = {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
}
|
||||
if (distanceVec2(nextPosition, boss.relocateTarget) <= 4 || boss.phaseSecondsRemaining <= 0) {
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'fireballWindup') {
|
||||
boss = {
|
||||
...boss,
|
||||
facing: withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing),
|
||||
}
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
const direction = withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing)
|
||||
projectiles.push({
|
||||
id: `boss-projectile-${nextProjectileId}`,
|
||||
sourceId: boss.id,
|
||||
owner: 'boss',
|
||||
projectileKind: 'fireball',
|
||||
color: '#ff8b2b',
|
||||
position: {
|
||||
x: boss.position.x + direction.x * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
|
||||
y: boss.position.y + direction.y * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2),
|
||||
},
|
||||
velocity: scaleVec2(direction, YIAN_KUT_KU_BOSS_METADATA.fireballSpeed!),
|
||||
radius: YIAN_KUT_KU_BOSS_METADATA.fireballRadius!,
|
||||
damage: YIAN_KUT_KU_BOSS_METADATA.fireballDamage!,
|
||||
remainingSeconds: 7,
|
||||
bouncesRemaining: YIAN_FIREBALL_BOUNCES,
|
||||
})
|
||||
nextProjectileId += 1
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'fireballRecover',
|
||||
phaseSecondsRemaining: 0.35,
|
||||
}
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
if (boss.attackPhase === 'fireballRecover' || boss.attackPhase === 'birdSummonRecover') {
|
||||
if (boss.phaseSecondsRemaining <= 0) {
|
||||
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldYianRelocate(boss, state)) {
|
||||
const center = yianCenterTarget(state)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
phaseSecondsRemaining: 1.4,
|
||||
relocateTarget: center,
|
||||
chargeStart: { ...boss.position },
|
||||
chargeEnd: center,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
const nextThreshold = nextBirdWaveThreshold(boss)
|
||||
if (nextThreshold !== undefined && boss.health / boss.maxHealth <= nextThreshold) {
|
||||
const spawn = spawnBirdWave(state, nextAddId, boss.id, nextThreshold)
|
||||
hostileAdds = [...hostileAdds, ...spawn.hostileAdds]
|
||||
nextAddId = spawn.nextAddId
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'birdSummonRecover',
|
||||
birdWaveThresholdsTriggered: [...boss.birdWaveThresholdsTriggered, nextThreshold],
|
||||
phaseSecondsRemaining: 0.55,
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
if (boss.fireballCooldownRemaining <= 0) {
|
||||
if (!isYianAtCenter(boss, state)) {
|
||||
const center = yianCenterTarget(state)
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'relocating',
|
||||
phaseSecondsRemaining: 1.4,
|
||||
relocateTarget: center,
|
||||
chargeStart: { ...boss.position },
|
||||
chargeEnd: center,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
boss = {
|
||||
...boss,
|
||||
attackPhase: 'fireballWindup',
|
||||
fireballCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballCooldown!,
|
||||
fireballTarget: { ...target.position },
|
||||
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballWindup!,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
|
||||
party = meleeResult.party
|
||||
boss = meleeResult.boss
|
||||
events.push(...meleeResult.events)
|
||||
if (meleeResult.events.length > 0) {
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(boss.position, yianApproachTarget(target, state), YIAN_KUT_KU_BOSS_METADATA.moveSpeed * dt),
|
||||
boss.radius,
|
||||
state.bounds,
|
||||
)
|
||||
boss = {
|
||||
...boss,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
|
||||
}
|
||||
return withYianIndicators({
|
||||
boss,
|
||||
events,
|
||||
hazards,
|
||||
hostileAdds,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
party,
|
||||
projectiles,
|
||||
})
|
||||
}
|
||||
|
||||
function shouldYianRelocate(
|
||||
boss: Iwt2BossEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
): boolean {
|
||||
return (
|
||||
boss.position.x < YIAN_SAFE_WALL_MARGIN
|
||||
|| boss.position.x > state.bounds.width - YIAN_SAFE_WALL_MARGIN
|
||||
|| boss.position.y < YIAN_SAFE_WALL_MARGIN
|
||||
|| boss.position.y > state.bounds.height - YIAN_SAFE_WALL_MARGIN
|
||||
)
|
||||
}
|
||||
|
||||
function isYianAtCenter(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
|
||||
return distanceVec2(boss.position, yianCenterTarget(state)) <= YIAN_CENTER_CAST_DISTANCE
|
||||
}
|
||||
|
||||
function yianApproachTarget(target: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return clampVec2ToArena({
|
||||
x: target.position.x + (target.position.x < state.bounds.width * 0.5 ? 190 : -190),
|
||||
y: target.position.y + (target.position.y < state.bounds.height * 0.5 ? 96 : -96),
|
||||
}, YIAN_KUT_KU_BOSS_METADATA.radius, state.bounds)
|
||||
}
|
||||
|
||||
function yianCenterTarget(state: Iwt2ArenaState): Iwt2Vec2 {
|
||||
return {
|
||||
x: state.bounds.width * 0.5,
|
||||
y: state.bounds.height * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
function tickYianBirds({
|
||||
dt,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
state,
|
||||
time,
|
||||
}: {
|
||||
dt: number
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
state: Iwt2ArenaState
|
||||
time: number
|
||||
}): {
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
party: Iwt2PartyEntityState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const hostileAdds: Iwt2HostileAddState[] = []
|
||||
let nextParty = party
|
||||
let nextHazards = hazards
|
||||
let nextHazardIdValue = nextHazardId
|
||||
|
||||
for (const add of state.hostileAdds) {
|
||||
if (add.health <= 0) continue
|
||||
let nextAdd = {
|
||||
...add,
|
||||
attackCooldownRemaining: Math.max(0, add.attackCooldownRemaining - dt),
|
||||
flightCooldownRemaining: Math.max(0, add.flightCooldownRemaining - dt),
|
||||
phaseSecondsRemaining: Math.max(0, add.phaseSecondsRemaining - dt),
|
||||
}
|
||||
|
||||
if (nextAdd.attackPhase === 'flightWindup') {
|
||||
if (nextAdd.phaseSecondsRemaining <= 0) {
|
||||
nextAdd = {
|
||||
...nextAdd,
|
||||
attackPhase: 'flying',
|
||||
position: { ...nextAdd.flightStart },
|
||||
velocity: scaleVec2(normalizeVec2(subtractVec2(nextAdd.flightEnd, nextAdd.flightStart)), YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed!),
|
||||
}
|
||||
}
|
||||
hostileAdds.push(nextAdd)
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextAdd.attackPhase === 'flying') {
|
||||
const previousPosition = nextAdd.position
|
||||
const nextPosition = moveToward(
|
||||
nextAdd.position,
|
||||
nextAdd.flightEnd,
|
||||
YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed! * dt,
|
||||
)
|
||||
const shape = {
|
||||
kind: 'lane' as const,
|
||||
start: previousPosition,
|
||||
end: nextPosition,
|
||||
width: nextAdd.radius,
|
||||
}
|
||||
const hitMembers = nextParty.filter((member) => (
|
||||
!nextAdd.flightHitEntityIds.includes(member.id)
|
||||
&& partyMemberIntersectsShape(member, shape)
|
||||
))
|
||||
const hitResult = applyPartyDamageInShape(nextParty, shape, {
|
||||
damage: BIRD_FLIGHT_DAMAGE,
|
||||
excludedEntityIds: nextAdd.flightHitEntityIds,
|
||||
knockdownSeconds: 0,
|
||||
sourceId: nextAdd.id,
|
||||
stunSeconds: YIAN_KUT_KU_BOSS_METADATA.birdStunSeconds!,
|
||||
time,
|
||||
})
|
||||
nextParty = hitResult.party
|
||||
events.push(...hitResult.events)
|
||||
for (const member of hitMembers) {
|
||||
const puddle = addFirePuddle({
|
||||
damage: YIAN_KUT_KU_BOSS_METADATA.firePuddleDamage!,
|
||||
duration: YIAN_KUT_KU_BOSS_METADATA.firePuddleSeconds!,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
position: member.position,
|
||||
radius: YIAN_KUT_KU_BOSS_METADATA.firePuddleRadius!,
|
||||
sourceId: nextAdd.id,
|
||||
time,
|
||||
})
|
||||
nextHazards = puddle.hazards
|
||||
nextHazardIdValue = puddle.nextHazardId
|
||||
}
|
||||
|
||||
nextAdd = {
|
||||
...nextAdd,
|
||||
flightHitEntityIds: [...nextAdd.flightHitEntityIds, ...hitResult.hitEntityIds],
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, previousPosition), dt > 0 ? 1 / dt : 0),
|
||||
facing: { x: 1, y: 0 },
|
||||
}
|
||||
if (distanceVec2(nextPosition, nextAdd.flightEnd) <= 1) {
|
||||
nextAdd = {
|
||||
...nextAdd,
|
||||
attackPhase: 'flightRecover',
|
||||
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
|
||||
phaseSecondsRemaining: 0.4,
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
hostileAdds.push(nextAdd)
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextAdd.attackPhase === 'flightRecover') {
|
||||
if (nextAdd.phaseSecondsRemaining <= 0) {
|
||||
nextAdd = { ...nextAdd, attackPhase: 'idle', phaseSecondsRemaining: 0 }
|
||||
}
|
||||
hostileAdds.push(nextAdd)
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextAdd.flightCooldownRemaining <= 0) {
|
||||
hostileAdds.push(startBirdFlight(nextAdd, state, time))
|
||||
continue
|
||||
}
|
||||
|
||||
const target = getNearestPartyMember(nextParty, nextAdd.position)
|
||||
if (!target) {
|
||||
hostileAdds.push({ ...nextAdd, velocity: { x: 0, y: 0 } })
|
||||
continue
|
||||
}
|
||||
|
||||
const nextPosition = clampVec2ToArena(
|
||||
moveToward(nextAdd.position, target.position, YIAN_KUT_KU_BOSS_METADATA.moveSpeed * 1.35 * dt),
|
||||
nextAdd.radius,
|
||||
state.bounds,
|
||||
)
|
||||
nextAdd = {
|
||||
...nextAdd,
|
||||
position: nextPosition,
|
||||
velocity: scaleVec2(subtractVec2(nextPosition, nextAdd.position), dt > 0 ? 1 / dt : 0),
|
||||
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), nextAdd.facing),
|
||||
}
|
||||
if (
|
||||
nextAdd.attackCooldownRemaining <= 0
|
||||
&& distanceVec2(nextAdd.position, target.position) <= nextAdd.radius + target.radius + BIRD_MELEE_RANGE
|
||||
) {
|
||||
const hitResult = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: nextAdd.position,
|
||||
radius: nextAdd.radius + BIRD_MELEE_RANGE,
|
||||
}, {
|
||||
damage: YIAN_KUT_KU_BOSS_METADATA.birdContactDamage!,
|
||||
sourceId: nextAdd.id,
|
||||
time,
|
||||
})
|
||||
nextParty = hitResult.party
|
||||
events.push(...hitResult.events)
|
||||
nextAdd = { ...nextAdd, attackCooldownRemaining: BIRD_MELEE_COOLDOWN }
|
||||
}
|
||||
hostileAdds.push(nextAdd)
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
hazards: nextHazards,
|
||||
hostileAdds,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
party: nextParty,
|
||||
}
|
||||
}
|
||||
|
||||
function spawnBirdWave(
|
||||
state: Iwt2ArenaState,
|
||||
nextAddId: number,
|
||||
sourceBossId: Iwt2BossId,
|
||||
threshold: number,
|
||||
): { hostileAdds: Iwt2HostileAddState[], nextAddId: number } {
|
||||
const hostileAdds: Iwt2HostileAddState[] = []
|
||||
let nextId = nextAddId
|
||||
for (let index = 0; index < BIRD_COUNT; index += 1) {
|
||||
const y = birdLaneY(state, index, threshold * 100)
|
||||
const radius = YIAN_KUT_KU_BOSS_METADATA.birdRadius!
|
||||
const flightStart = { x: -radius - 8, y }
|
||||
const flightEnd = { x: state.bounds.width + radius + 8, y }
|
||||
hostileAdds.push({
|
||||
id: `yian-bird-${nextId}`,
|
||||
kind: 'hostileAdd',
|
||||
addKind: 'yian-bird',
|
||||
sourceBossId,
|
||||
position: { ...flightStart },
|
||||
velocity: { x: 0, y: 0 },
|
||||
facing: { x: 1, y: 0 },
|
||||
radius,
|
||||
health: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
|
||||
maxHealth: YIAN_KUT_KU_BOSS_METADATA.birdHealth!,
|
||||
damageDone: 0,
|
||||
attackCooldownRemaining: 0,
|
||||
flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!,
|
||||
attackPhase: 'flightWindup',
|
||||
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
|
||||
flightStart,
|
||||
flightEnd,
|
||||
flightHitEntityIds: [],
|
||||
})
|
||||
nextId += 1
|
||||
}
|
||||
return { hostileAdds, nextAddId: nextId }
|
||||
}
|
||||
|
||||
function startBirdFlight(add: Iwt2HostileAddState, state: Iwt2ArenaState, time: number): Iwt2HostileAddState {
|
||||
const y = birdLaneY(state, Number(add.id.replace(/\D/g, '')) % BIRD_COUNT, time)
|
||||
const flightStart = { x: -add.radius - 8, y }
|
||||
const flightEnd = { x: state.bounds.width + add.radius + 8, y }
|
||||
return {
|
||||
...add,
|
||||
attackPhase: 'flightWindup',
|
||||
phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!,
|
||||
flightStart,
|
||||
flightEnd,
|
||||
flightHitEntityIds: [],
|
||||
velocity: { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function birdLaneY(state: Iwt2ArenaState, index: number, seed: number): number {
|
||||
const laneHeight = state.bounds.height / (BIRD_COUNT + 1)
|
||||
const base = laneHeight * (index + 1)
|
||||
const wobble = Math.sin(seed * 8.37 + index * 2.1) * 34
|
||||
return clamp(base + wobble, 58, state.bounds.height - 58)
|
||||
}
|
||||
|
||||
function nextBirdWaveThreshold(boss: Iwt2BossEntityState): number | undefined {
|
||||
return YIAN_KUT_KU_BOSS_METADATA.birdWaveThresholds
|
||||
?.find((threshold) => !boss.birdWaveThresholdsTriggered.includes(threshold))
|
||||
}
|
||||
|
||||
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
|
||||
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
|
||||
if (livingTank) return livingTank
|
||||
return party.find((member) => member.health > 0)
|
||||
}
|
||||
|
||||
function getNearestPartyMember(party: Iwt2PartyEntityState[], position: Iwt2Vec2): Iwt2PartyEntityState | undefined {
|
||||
let nearest: Iwt2PartyEntityState | undefined
|
||||
let nearestDistance = Number.POSITIVE_INFINITY
|
||||
for (const member of party) {
|
||||
if (member.health <= 0) continue
|
||||
const distance = distanceVec2(member.position, position)
|
||||
if (distance < nearestDistance) {
|
||||
nearest = member
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function maybeApplyMelee(
|
||||
party: Iwt2PartyEntityState[],
|
||||
boss: Iwt2BossEntityState,
|
||||
target: Iwt2PartyEntityState,
|
||||
time: number,
|
||||
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
|
||||
if (distanceVec2(boss.position, target.position) > YIAN_KUT_KU_BOSS_METADATA.meleeRange + target.radius) {
|
||||
return { boss, party, events: [] }
|
||||
}
|
||||
const result = applyPartyDamageInShape(party, {
|
||||
kind: 'circle',
|
||||
position: boss.position,
|
||||
radius: YIAN_KUT_KU_BOSS_METADATA.meleeRange,
|
||||
}, {
|
||||
damage: YIAN_KUT_KU_BOSS_METADATA.meleeDamage,
|
||||
sourceId: boss.id,
|
||||
time,
|
||||
})
|
||||
return {
|
||||
boss: { ...boss, meleeCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.meleeCooldown },
|
||||
party: result.party,
|
||||
events: result.events,
|
||||
}
|
||||
}
|
||||
|
||||
function withYianIndicators(result: Omit<Iwt2YianTickResult, 'indicators'>): Iwt2YianTickResult {
|
||||
return {
|
||||
...result,
|
||||
indicators: createYianIndicators(result.boss, result.hostileAdds),
|
||||
}
|
||||
}
|
||||
|
||||
function createYianIndicators(
|
||||
boss: Iwt2BossEntityState,
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
): Iwt2ArenaIndicator[] {
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
if (boss.attackPhase === 'relocating') {
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#ffce5c',
|
||||
end: boss.chargeEnd,
|
||||
id: `${boss.id}:center-charge`,
|
||||
mechanicId: 'yian-center-charge',
|
||||
phase: 'active',
|
||||
sourceId: boss.id,
|
||||
start: boss.chargeStart,
|
||||
width: boss.radius,
|
||||
}))
|
||||
}
|
||||
if (boss.attackPhase === 'fireballWindup') {
|
||||
indicators.push(createCircleIndicator({
|
||||
color: '#ff8b2b',
|
||||
id: `${boss.id}:fireball-cast`,
|
||||
mechanicId: 'yian-fireball-cast',
|
||||
phase: 'windup',
|
||||
position: boss.position,
|
||||
radius: boss.radius + 18,
|
||||
sourceId: boss.id,
|
||||
}))
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#ffb347',
|
||||
end: boss.fireballTarget,
|
||||
id: `${boss.id}:fireball-line`,
|
||||
mechanicId: 'yian-fireball-line',
|
||||
phase: 'windup',
|
||||
sourceId: boss.id,
|
||||
start: boss.position,
|
||||
width: YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 4,
|
||||
}))
|
||||
}
|
||||
for (const add of hostileAdds) {
|
||||
if (
|
||||
add.attackPhase !== 'flightWindup'
|
||||
&& add.attackPhase !== 'flying'
|
||||
&& add.attackPhase !== 'flightRecover'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
indicators.push(createLaneIndicator({
|
||||
color: '#ffce5c',
|
||||
end: add.flightEnd,
|
||||
id: `${add.id}:flight-lane`,
|
||||
mechanicId: 'yian-bird-flight',
|
||||
phase: indicatorPhaseFromAttack(
|
||||
add.attackPhase === 'flightWindup',
|
||||
add.attackPhase === 'flying',
|
||||
),
|
||||
sourceId: add.id,
|
||||
start: add.flightStart,
|
||||
width: add.radius,
|
||||
}))
|
||||
}
|
||||
return indicators
|
||||
}
|
||||
Reference in New Issue
Block a user