Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdae0007a1 | ||
|
|
c052b086f8 | ||
|
|
c2a14bac7d | ||
|
|
0d269a6041 | ||
|
|
99e97e5208 |
@@ -34,6 +34,7 @@
|
||||
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
||||
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
||||
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
||||
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
|
||||
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
|
||||
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 93
|
||||
versionName "1.1.14"
|
||||
versionCode 98
|
||||
versionName "1.1.19"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -164,6 +164,9 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
}
|
||||
|
||||
Set<String> currentTokens = new HashSet<>();
|
||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||
@@ -178,13 +181,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_X),
|
||||
leftStickX,
|
||||
"Axis0-",
|
||||
"Axis0+"
|
||||
);
|
||||
addAxisTokens(
|
||||
currentTokens,
|
||||
event.getAxisValue(MotionEvent.AXIS_Y),
|
||||
leftStickY,
|
||||
"Axis1-",
|
||||
"Axis1+"
|
||||
);
|
||||
@@ -240,6 +243,19 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
);
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerMotion(float x, float y) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller-motion',"
|
||||
+ "{detail:{x:" + x + ",y:" + y + "}}));";
|
||||
bridge.getWebView().post(
|
||||
() -> {
|
||||
bridge.getWebView().requestFocus();
|
||||
bridge.getWebView().evaluateJavascript(script, null);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private boolean shouldThrottleDpad(String token) {
|
||||
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
|
||||
long now = SystemClock.uptimeMillis();
|
||||
|
||||
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"
|
||||
},
|
||||
|
||||
+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
|
||||
|
||||
+1511
-6
File diff suppressed because it is too large
Load Diff
+79
-1543
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 (
|
||||
|
||||
@@ -49,6 +49,11 @@ export type DualScreenCombatState = {
|
||||
opponentClassName?: string
|
||||
opponentParty?: PartyMember[]
|
||||
opponentEnemyHealth?: number
|
||||
opponentArena?: {
|
||||
bounds: { width: number, height: number }
|
||||
bosses: Array<{ id: string, name: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number }>
|
||||
party: Array<{ id: string, icon: string, color: string, x: number, y: number, radius: number, health: number, maxHealth: number, isHealer: boolean }>
|
||||
}
|
||||
opponentResource?: number
|
||||
opponentMaxResource?: number
|
||||
opponentResourceName?: string
|
||||
@@ -638,6 +643,48 @@ export function DualScreenBottomDisplay() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{state.opponentArena && (
|
||||
<section className="dual-opponent-arena" aria-label="Opponent arena">
|
||||
<div
|
||||
className="dual-opponent-arena-field"
|
||||
style={{ aspectRatio: `${state.opponentArena.bounds.width} / ${state.opponentArena.bounds.height}` }}
|
||||
>
|
||||
{state.opponentArena.bosses.map((boss) => (
|
||||
<div
|
||||
className="dual-opponent-arena-entity boss"
|
||||
key={boss.id}
|
||||
style={{
|
||||
backgroundColor: boss.color,
|
||||
height: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
||||
left: `${(boss.x / state.opponentArena!.bounds.width) * 100}%`,
|
||||
top: `${(boss.y / state.opponentArena!.bounds.height) * 100}%`,
|
||||
width: `${Math.max(8, (boss.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
||||
}}
|
||||
title={`${boss.name} ${Math.ceil(boss.health)} / ${boss.maxHealth}`}
|
||||
>
|
||||
{boss.icon}
|
||||
</div>
|
||||
))}
|
||||
{state.opponentArena.party.map((member) => (
|
||||
<div
|
||||
className={`dual-opponent-arena-entity party ${member.isHealer ? 'healer' : ''}`}
|
||||
key={member.id}
|
||||
style={{
|
||||
backgroundColor: member.color,
|
||||
height: `${Math.max(5, (member.radius / state.opponentArena!.bounds.height) * 100)}%`,
|
||||
left: `${(member.x / state.opponentArena!.bounds.width) * 100}%`,
|
||||
opacity: member.health > 0 ? 1 : 0.35,
|
||||
top: `${(member.y / state.opponentArena!.bounds.height) * 100}%`,
|
||||
width: `${Math.max(5, (member.radius / state.opponentArena!.bounds.width) * 100)}%`,
|
||||
}}
|
||||
>
|
||||
{member.icon}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={`dual-opponent-party-grid ${state.opponentParty.length > 6 ? 'raid' : ''}`}>
|
||||
{state.opponentParty.map((member) => (
|
||||
<PartyMemberFrame
|
||||
|
||||
+105
-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',
|
||||
@@ -130,14 +134,16 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
const NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
|
||||
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
|
||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display'
|
||||
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
|
||||
const HEADER_CONTENT_SELECTOR = '.app-header'
|
||||
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
|
||||
const GAMEPAD_MENU_POLL_MS = 1000 / 30
|
||||
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
|
||||
const CONTROLLER_REPEAT_INITIAL_MS = 260
|
||||
const CONTROLLER_REPEAT_MS = 85
|
||||
const NATIVE_STICK_TIMEOUT_MS = 140
|
||||
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
@@ -235,13 +241,13 @@ function loadPreferences() {
|
||||
combatTouchLocked?: boolean
|
||||
}
|
||||
return {
|
||||
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
|
||||
controllerIconStyle: saved.controllerIconStyle ?? 'playstation',
|
||||
directPartyTargeting: saved.directPartyTargeting ?? false,
|
||||
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
||||
controllerIconStyle: 'playstation' as ControllerIconStyle,
|
||||
directPartyTargeting: false,
|
||||
combatTouchLocked: Capacitor.isNativePlatform(),
|
||||
}
|
||||
@@ -530,6 +536,10 @@ function isCombatActive() {
|
||||
return Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
}
|
||||
|
||||
function hasContinuousMovementActive() {
|
||||
return Boolean(document.querySelector('[data-continuous-movement-active="true"]'))
|
||||
}
|
||||
|
||||
function firstConnectedGamepad() {
|
||||
return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
|
||||
}
|
||||
@@ -686,6 +696,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
document.querySelector('[data-combat-active="true"]'),
|
||||
)
|
||||
const uiOverlay = hasUiOverlay()
|
||||
if (
|
||||
combatActive
|
||||
&& !uiOverlay
|
||||
&& hasContinuousMovementActive()
|
||||
&& (token.startsWith('Axis0') || token.startsWith('Axis1'))
|
||||
) return
|
||||
const uiPriority = [
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
@@ -1046,6 +1062,92 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedVector(x: number, y: number): MovementVector {
|
||||
const magnitude = Math.hypot(x, y)
|
||||
if (magnitude <= 1) return { x, y }
|
||||
return { x: x / magnitude, y: y / magnitude }
|
||||
}
|
||||
|
||||
function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector {
|
||||
const magnitude = Math.hypot(x, y)
|
||||
if (magnitude < deadzone) return { x: 0, y: 0 }
|
||||
const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone))
|
||||
return {
|
||||
x: (x / magnitude) * scaled,
|
||||
y: (y / magnitude) * scaled,
|
||||
}
|
||||
}
|
||||
|
||||
export function useMovementVectorRef(enabled = true) {
|
||||
const movementRef = useRef<MovementVector>({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
movementRef.current = { x: 0, y: 0 }
|
||||
return undefined
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
const pressedKeys = new Set<string>()
|
||||
let nativeStick: MovementVector = { x: 0, y: 0 }
|
||||
let nativeStickUpdatedAt = 0
|
||||
const keyboardVector = () => normalizedVector(
|
||||
(pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
|
||||
(pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
|
||||
)
|
||||
const updateMovement = () => {
|
||||
const gamepad = firstConnectedGamepad()
|
||||
const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
|
||||
const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
|
||||
? applyStickDeadzone(nativeStick.x, nativeStick.y)
|
||||
: { x: 0, y: 0 }
|
||||
const keyboard = keyboardVector()
|
||||
movementRef.current = Math.hypot(native.x, native.y) > 0
|
||||
? native
|
||||
: Math.hypot(stick.x, stick.y) > 0
|
||||
? stick
|
||||
: keyboard
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
}
|
||||
const onNativeMotion = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Partial<MovementVector>>).detail
|
||||
nativeStick = {
|
||||
x: Number(detail.x) || 0,
|
||||
y: Number(detail.y) || 0,
|
||||
}
|
||||
nativeStickUpdatedAt = performance.now()
|
||||
document.documentElement.dataset.inputDevice = 'controller'
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isTextInput(document.activeElement)) return
|
||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||
pressedKeys.add(event.code)
|
||||
document.documentElement.dataset.inputDevice = 'pc'
|
||||
event.preventDefault()
|
||||
}
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
|
||||
pressedKeys.delete(event.code)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
frame = window.requestAnimationFrame(updateMovement)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
movementRef.current = { x: 0, y: 0 }
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return movementRef
|
||||
}
|
||||
|
||||
export function useInput() {
|
||||
const context = useContext(InputContext)
|
||||
if (!context) throw new Error('useInput must be used inside InputProvider')
|
||||
|
||||
+9
-1
@@ -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,642 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGameAction } from '../../input'
|
||||
import { getGameMode } from '../../gameRepository'
|
||||
import { startPvpQueueWithCpuFallback } from '../../pvpQueueLifecycle'
|
||||
import { summarizeChoiceStacks } from '../../combat/roguelikeUpgrades'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenSetupPublisher,
|
||||
useDualScreenWorkshopPublisher,
|
||||
type DualScreenSetupState,
|
||||
type DualScreenWorkshopState,
|
||||
} from '../../dualScreen'
|
||||
import { BossArenaScreen } from './screens/BossArenaScreen'
|
||||
import {
|
||||
Iwt2CloudSaveScreen,
|
||||
Iwt2CustomizeCharacterScreen,
|
||||
Iwt2DungeonsScreen,
|
||||
Iwt2HunterProfileScreen,
|
||||
Iwt2ModeScreen,
|
||||
Iwt2RoguelikeScreen,
|
||||
Iwt2RoguelikeUpgradeScreen,
|
||||
Iwt2SettingsScreen,
|
||||
} from './screens/Iwt2ShellScreens'
|
||||
import {
|
||||
loadIwt2Save,
|
||||
writeIwt2Save,
|
||||
type Iwt2Save,
|
||||
} from './save/iwt2Repository'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import {
|
||||
buildIwt2OpponentDebuffChoices,
|
||||
buildIwt2SelfBuffChoices,
|
||||
IWT2_REVIVE_PARTY_CHOICE,
|
||||
type Iwt2RoguelikeChoice,
|
||||
type Iwt2RoguelikeContentType,
|
||||
type Iwt2RoguelikeOpponentDebuffId,
|
||||
type Iwt2RoguelikeSelfBuffId,
|
||||
type Iwt2RoguelikeVariant,
|
||||
} from './content/roguelike'
|
||||
|
||||
type Iwt2Screen =
|
||||
| 'menu'
|
||||
| 'arena'
|
||||
| 'cloud-save'
|
||||
| 'dungeons'
|
||||
| 'raids'
|
||||
| 'roguelike'
|
||||
| 'roguelike-arena'
|
||||
| 'roguelike-upgrade'
|
||||
| 'hunter-profile'
|
||||
| 'customize-character'
|
||||
| 'settings'
|
||||
|
||||
const IWT2_MENU_COLUMNS = 4
|
||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||
|
||||
type Iwt2RoguelikeRunState = {
|
||||
bossIds: Iwt2BossId[]
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
debuffChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
|
||||
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}
|
||||
|
||||
const MENU_ITEMS: Array<{
|
||||
screen: Iwt2Screen
|
||||
title: string
|
||||
description: string
|
||||
glyph: string
|
||||
}> = [
|
||||
{
|
||||
screen: 'cloud-save',
|
||||
title: 'Backup Slot',
|
||||
description: 'Save or restore the isolated IWT2 progress slot.',
|
||||
glyph: 'C',
|
||||
},
|
||||
{
|
||||
screen: 'dungeons',
|
||||
title: 'Dungeons',
|
||||
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
|
||||
glyph: 'D',
|
||||
},
|
||||
{
|
||||
screen: 'raids',
|
||||
title: 'Raids',
|
||||
description: 'Open raid assignments built from active IWT2 boss mechanics.',
|
||||
glyph: 'R',
|
||||
},
|
||||
{
|
||||
screen: 'roguelike',
|
||||
title: 'Roguelike',
|
||||
description: 'Draft upgrades through escalating random encounters.',
|
||||
glyph: 'L',
|
||||
},
|
||||
{
|
||||
screen: 'roguelike',
|
||||
title: 'PvP',
|
||||
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||
glyph: 'P',
|
||||
},
|
||||
{
|
||||
screen: 'hunter-profile',
|
||||
title: 'Hunter Profile',
|
||||
description: 'IWT2 level, inventory summary, and collection log.',
|
||||
glyph: 'H',
|
||||
},
|
||||
{
|
||||
screen: 'customize-character',
|
||||
title: 'Customize Character',
|
||||
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||
glyph: 'K',
|
||||
},
|
||||
{
|
||||
screen: 'settings',
|
||||
title: 'Settings',
|
||||
description: 'IWT2 targeting preference and controller icon style.',
|
||||
glyph: 'S',
|
||||
},
|
||||
]
|
||||
|
||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
writeIwt2Save(save)
|
||||
}, [save])
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
}, [])
|
||||
|
||||
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
||||
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
|
||||
[save, screen, selectedBossId],
|
||||
)
|
||||
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||
[save, screen],
|
||||
)
|
||||
|
||||
useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled)
|
||||
useDualScreenWorkshopPublisher(workshopDualScreenState, dualScreenEnabled)
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (screen !== 'menu' || device !== 'controller') return
|
||||
if (action === 'back') {
|
||||
onBackToGameSelect()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openMenuItem(MENU_ITEMS[selectedIndex])
|
||||
return
|
||||
}
|
||||
if (action === 'navigateUp' || action === 'navigateLeft') {
|
||||
const offset = action === 'navigateUp' ? IWT2_MENU_COLUMNS : 1
|
||||
setSelectedIndex((current) => Math.max(0, current - offset))
|
||||
} else if (action === 'navigateDown' || action === 'navigateRight') {
|
||||
const offset = action === 'navigateDown' ? IWT2_MENU_COLUMNS : 1
|
||||
setSelectedIndex((current) => Math.min(MENU_ITEMS.length - 1, current + offset))
|
||||
}
|
||||
})
|
||||
|
||||
function openMenuItem(item: (typeof MENU_ITEMS)[number]) {
|
||||
if (item.title === 'PvP') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
if (item.title === 'Roguelike') {
|
||||
setRoguelikeVariant('pve')
|
||||
setRoguelikeContentType((current) => current === 'stadium' ? 'dungeon' : current)
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}
|
||||
|
||||
if (screen === 'arena') {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike-arena' && roguelikeRun) {
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossIds={roguelikeRun.bossIds}
|
||||
bossId={selectedBossId}
|
||||
key={`roguelike-${roguelikeRun.variant}-${roguelikeRun.contentType}-${roguelikeRun.stage}-${roguelikeRun.bossIds.join('-')}`}
|
||||
roguelikeRun={{
|
||||
buffs: roguelikeRun.buffs,
|
||||
contentType: roguelikeRun.contentType,
|
||||
debuffs: roguelikeRun.debuffs,
|
||||
onVictory: () => {
|
||||
setRoguelikeRun((current) => current
|
||||
? {
|
||||
...current,
|
||||
...buildRoguelikeChoices(save, current.variant),
|
||||
}
|
||||
: current)
|
||||
setScreen('roguelike-upgrade')
|
||||
},
|
||||
stage: roguelikeRun.stage,
|
||||
variant: roguelikeRun.variant,
|
||||
}}
|
||||
save={save}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2RoguelikeUpgradeScreen
|
||||
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||
contentType={roguelikeRun.contentType}
|
||||
debuffChoices={roguelikeRun.debuffChoices}
|
||||
selfChoices={roguelikeRun.selfChoices}
|
||||
stage={roguelikeRun.stage}
|
||||
variant={roguelikeRun.variant}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onChoose={(buffId, debuffId) => {
|
||||
const nextRun = applyRoguelikeChoice(roguelikeRun, buffId, debuffId, save)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'dungeons') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2DungeonsScreen
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Dungeon')
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'roguelike') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2RoguelikeScreen
|
||||
contentType={roguelikeContentType}
|
||||
variant={roguelikeVariant}
|
||||
onBack={() => setScreen('menu')}
|
||||
onContentTypeChange={setRoguelikeContentType}
|
||||
onStart={() => {
|
||||
startIwt2RoguelikeRun()
|
||||
}}
|
||||
onVariantChange={(nextVariant) => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
setRoguelikeVariant(nextVariant)
|
||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||
setRoguelikeContentType('dungeon')
|
||||
}
|
||||
}}
|
||||
queueMessage={pvpQueueMessage}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'raids') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2ModeScreen
|
||||
mode="Raids"
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Raid')
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'hunter-profile') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'customize-character') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2CustomizeCharacterScreen
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'cloud-save') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2CloudSaveScreen
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'settings') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
|
||||
{screen === 'menu' && (
|
||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||
<div className="iwt2-menu-heading">
|
||||
<p className="eyebrow">I Want To Heal 2</p>
|
||||
<h1>Mode Select</h1>
|
||||
</div>
|
||||
<div className="iwt2-menu-grid">
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<button
|
||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||
key={`${item.screen}-${item.title}`}
|
||||
onClick={() => openMenuItem(item)}
|
||||
onPointerDown={() => setSelectedIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.description}</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
|
||||
function startIwt2RoguelikeRun() {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
if (roguelikeVariant !== 'pvp') {
|
||||
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||
return
|
||||
}
|
||||
const startStage = 1
|
||||
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||
contentType: roguelikeContentType,
|
||||
startStage,
|
||||
gameMode: getGameMode(),
|
||||
liveMatchActive: () => false,
|
||||
onSearching: setPvpQueueMessage,
|
||||
onCpuMatch: (_difficulty, message) => {
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
onLiveMatch: (...liveMatchArgs) => {
|
||||
const message = liveMatchArgs[2]
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
messages: {
|
||||
offline: (difficulty) => `Offline mode. CPU ${difficulty} enters IWT2 ${formatRoguelikeContentType(roguelikeContentType)}.`,
|
||||
searching: `Searching IWT2 ${formatRoguelikeContentType(roguelikeContentType)} queue for 5s.`,
|
||||
notFound: (difficulty) => `No IWT2 opponent found after 5s. CPU ${difficulty} steps in.`,
|
||||
unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
|
||||
liveFound: () => `Opponent found. Starting IWT2 ${formatRoguelikeContentType(roguelikeContentType)} race.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function beginIwt2RoguelikeArena(
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
) {
|
||||
const nextRun = createRoguelikeRun(save, variant, contentType)
|
||||
setRoguelikeRun(nextRun)
|
||||
setSelectedBossId(nextRun.bossIds[0] ?? 'bulldrome')
|
||||
setScreen('roguelike-arena')
|
||||
}
|
||||
}
|
||||
|
||||
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function createRoguelikeRun(
|
||||
save: Iwt2Save,
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
buffs: [],
|
||||
contentType,
|
||||
debuffs: [],
|
||||
...buildRoguelikeChoices(save, variant),
|
||||
stage: 1,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||
return {
|
||||
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||
debuffChoices: variant === 'pvp'
|
||||
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeIwt2Buffs(save: Iwt2Save, buffs: Iwt2RoguelikeSelfBuffId[]) {
|
||||
if (buffs.length === 0) return ''
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
return summarizeChoiceStacks(
|
||||
buffs,
|
||||
[IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)],
|
||||
'None',
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeIwt2Debuffs(save: Iwt2Save, debuffs: Iwt2RoguelikeOpponentDebuffId[]) {
|
||||
if (debuffs.length === 0) return ''
|
||||
const abilities = abilitiesForHealer(save.character.healerStyle)
|
||||
return summarizeChoiceStacks(
|
||||
debuffs,
|
||||
buildIwt2OpponentDebuffChoices(abilities),
|
||||
'None',
|
||||
)
|
||||
}
|
||||
|
||||
function applyRoguelikeChoice(
|
||||
run: Iwt2RoguelikeRunState,
|
||||
buffId: Iwt2RoguelikeSelfBuffId,
|
||||
debuffId: Iwt2RoguelikeOpponentDebuffId | undefined,
|
||||
save: Iwt2Save,
|
||||
): Iwt2RoguelikeRunState {
|
||||
const nextDebuffs = debuffId ? [...run.debuffs, debuffId] : run.debuffs
|
||||
const nextBase = buffId === IWT2_REVIVE_PARTY_CHOICE.id
|
||||
? {
|
||||
buffs: run.buffs,
|
||||
debuffs: nextDebuffs.slice(1),
|
||||
}
|
||||
: {
|
||||
buffs: [...run.buffs, buffId],
|
||||
debuffs: nextDebuffs,
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
...nextBase,
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
...buildRoguelikeChoices(save, run.variant),
|
||||
stage: run.stage + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
||||
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||
return chooseRunChoices(pool, 2)
|
||||
}
|
||||
|
||||
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||
const pool = [...items]
|
||||
const choices: T[] = []
|
||||
while (pool.length > 0 && choices.length < count) {
|
||||
const index = Math.floor(Math.random() * pool.length)
|
||||
const [choice] = pool.splice(index, 1)
|
||||
if (choice) choices.push(choice)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
function Iwt2Header({
|
||||
onBackToGameSelect,
|
||||
save,
|
||||
}: {
|
||||
onBackToGameSelect: () => void
|
||||
save: Iwt2Save
|
||||
}) {
|
||||
return (
|
||||
<header className="topbar app-header">
|
||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
||||
<strong>Games</strong>
|
||||
</button>
|
||||
<div className="character-summary">
|
||||
<strong>{save.character.name}</strong>
|
||||
<small>IWT2 Level {save.character.level}</small>
|
||||
<small>{save.character.experience} XP</small>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function buildIwt2SetupDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
selectedBossId: Iwt2BossId,
|
||||
save: Iwt2Save,
|
||||
): DualScreenSetupState | null {
|
||||
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||
const raid = screen === 'raids'
|
||||
return {
|
||||
contentType: raid ? 'raid' : 'dungeon',
|
||||
description: raid
|
||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
||||
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
|
||||
difficultyName: `IWT2 Level ${save.character.level}`,
|
||||
experience: 125,
|
||||
initials: boss.icon,
|
||||
itemLevel: save.character.level,
|
||||
stats: {
|
||||
damage: `${boss.meleeDamage}`,
|
||||
health: `${boss.maxHealth}`,
|
||||
loot: 'IWT2',
|
||||
xp: '125',
|
||||
},
|
||||
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
||||
}
|
||||
}
|
||||
|
||||
function buildIwt2WorkshopDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
save: Iwt2Save,
|
||||
): DualScreenWorkshopState | null {
|
||||
if (screen === 'hunter-profile') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
glyph: 'H',
|
||||
meta: `${save.character.experience} XP`,
|
||||
status: `Level ${save.character.level}`,
|
||||
title: save.character.name,
|
||||
},
|
||||
{
|
||||
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
|
||||
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
|
||||
status: 'Class',
|
||||
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
|
||||
},
|
||||
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
|
||||
glyph: IWT2_BOSS_METADATA[bossId].icon,
|
||||
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
|
||||
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
|
||||
title: IWT2_BOSS_METADATA[bossId].name,
|
||||
})),
|
||||
],
|
||||
mode: 'collection',
|
||||
subtitle: 'IWT2 inventory and collection log',
|
||||
summary: `${save.inventory.length} inventory slots`,
|
||||
title: 'Hunter Profile',
|
||||
}
|
||||
}
|
||||
|
||||
if (screen === 'customize-character') {
|
||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||
return {
|
||||
items: abilitiesForHealer(save.character.healerStyle).map((ability) => ({
|
||||
glyph: ability.icon,
|
||||
meta: `${ability.manaCost} Mana | ${ability.cooldownSeconds}s cooldown`,
|
||||
status: `Slot ${ability.slot}`,
|
||||
title: ability.name,
|
||||
})),
|
||||
mode: 'class',
|
||||
subtitle: healer.description,
|
||||
summary: healer.name,
|
||||
title: 'Customize Character',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
|
||||
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
|
||||
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
|
||||
if (bossId === 'khezu') return 'khezu-pearl'
|
||||
return 'raw-bulldrome-coin'
|
||||
}
|
||||
@@ -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,24 @@
|
||||
import type { Iwt2BossEntityState } from '../sim'
|
||||
import { ArenaBar } from './ArenaBars'
|
||||
import { IWT2_BOSS_METADATA } from '../content/bosses'
|
||||
|
||||
export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?: Iwt2BossEntityState[] }) {
|
||||
const entries = bosses ?? (boss ? [boss] : [])
|
||||
return (
|
||||
<div className="iwt2-boss-hud">
|
||||
{entries.map((entry) => {
|
||||
const metadata = IWT2_BOSS_METADATA[entry.bossId]
|
||||
return (
|
||||
<div className="iwt2-boss-hud-row" key={entry.id}>
|
||||
<div>
|
||||
<strong>{metadata.name}</strong>
|
||||
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
|
||||
</div>
|
||||
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
|
||||
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ControllerBindingLabel } from '../../../components/ControllerIcons'
|
||||
import type { ControllerIconStyle } from '../../../input'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import type { Iwt2EntityId, Iwt2PartyEntityState } from '../sim'
|
||||
import { ArenaBar } from './ArenaBars'
|
||||
|
||||
export function PartyFrames({
|
||||
party,
|
||||
selectedPartyId,
|
||||
targetBindings,
|
||||
controllerIconStyle,
|
||||
onTarget,
|
||||
}: {
|
||||
party: Iwt2PartyEntityState[]
|
||||
selectedPartyId: Iwt2EntityId
|
||||
targetBindings?: Array<string | null>
|
||||
controllerIconStyle?: ControllerIconStyle
|
||||
onTarget: (id: Iwt2EntityId) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="iwt2-party-list" data-game-nav-active="true">
|
||||
{party.map((member) => {
|
||||
const meta = IWT2_CLASS_METADATA[member.classId]
|
||||
const selected = member.id === selectedPartyId
|
||||
const targetBinding = targetBindings?.[party.indexOf(member)]
|
||||
return (
|
||||
<button
|
||||
className={`iwt2-party-row ${targetBinding ? 'has-target-binding' : ''} ${selected ? 'game-selected selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected ? 'true' : undefined}
|
||||
key={member.id}
|
||||
onClick={() => onTarget(member.id)}
|
||||
type="button"
|
||||
>
|
||||
{targetBinding && (
|
||||
<span className="iwt2-party-target-key">
|
||||
<ControllerBindingLabel
|
||||
binding={targetBinding}
|
||||
iconStyle={controllerIconStyle ?? 'playstation'}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span style={{ background: meta.color }}>{meta.icon}</span>
|
||||
<div>
|
||||
<div className="iwt2-party-row-title">
|
||||
<strong>{meta.name}</strong>
|
||||
<small>{Math.ceil(member.health)}</small>
|
||||
</div>
|
||||
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
|
||||
{member.maxMana > 0 && (
|
||||
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
||||
)}
|
||||
<div className="iwt2-party-effects">
|
||||
{member.hotEffects.map((effect) => (
|
||||
<span className="iwt2-effect-badge hot" key={effect.id}>
|
||||
{effect.label} {Math.ceil(effect.remainingSeconds)}s
|
||||
</span>
|
||||
))}
|
||||
{member.shield > 0 && (
|
||||
<span className="iwt2-effect-badge shield">
|
||||
Shield {Math.ceil(member.shield)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<small className="iwt2-party-damage">Damage {Math.round(member.damageDone)}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Iwt2BalanceOverrides } from './bosses'
|
||||
|
||||
// Generated by local admin panel. Commit this file with intended IWT2 balance changes.
|
||||
export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = {
|
||||
bosses: {},
|
||||
}
|
||||
@@ -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.bosses]
|
||||
const liveIds = new Set<string>(entities.map((entity) => entity.id))
|
||||
|
||||
for (const entity of entities.sort(entitySort)) {
|
||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
||||
const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1
|
||||
const color = entityColor(entity)
|
||||
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
|
||||
graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1)
|
||||
if (entity.kind === 'boss') {
|
||||
graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
|
||||
graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75)
|
||||
} else if (entity.kind === 'hostileAdd') {
|
||||
const wing = entity.radius * 1.55
|
||||
graphics.fillTriangle(
|
||||
entity.position.x + entity.facing.x * wing,
|
||||
entity.position.y,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y - entity.radius,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y + entity.radius,
|
||||
)
|
||||
graphics.strokeTriangle(
|
||||
entity.position.x + entity.facing.x * wing,
|
||||
entity.position.y,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y - entity.radius,
|
||||
entity.position.x - entity.facing.x * entity.radius,
|
||||
entity.position.y + entity.radius,
|
||||
)
|
||||
} else {
|
||||
if (entity.id === this.deps.getSelectedPartyId()) {
|
||||
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
||||
}
|
||||
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
||||
}
|
||||
this.drawLabel(entity)
|
||||
this.drawHealthBar(graphics, entity)
|
||||
}
|
||||
|
||||
for (const [id, label] of this.labels) {
|
||||
if (!liveIds.has(id)) {
|
||||
label.destroy()
|
||||
this.labels.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawProjectiles(state: Iwt2ArenaState) {
|
||||
const graphics = this.entityGraphics!
|
||||
for (const projectile of state.projectiles) {
|
||||
const color = Number.parseInt(projectile.color.slice(1), 16)
|
||||
graphics.fillStyle(color, 0.95)
|
||||
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
|
||||
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
|
||||
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
|
||||
}
|
||||
}
|
||||
|
||||
private drawLabel(entity: DrawableEntity) {
|
||||
let label = this.labels.get(entity.id)
|
||||
const icon = entityIcon(entity)
|
||||
if (!label) {
|
||||
label = this.add.text(entity.position.x, entity.position.y, icon, {
|
||||
align: 'center',
|
||||
color: '#fff7df',
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
fontSize: entity.kind === 'boss' ? '20px' : entity.kind === 'hostileAdd' ? '13px' : '15px',
|
||||
fontStyle: '900',
|
||||
}).setOrigin(0.5)
|
||||
this.labels.set(entity.id, label)
|
||||
}
|
||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
||||
label.setText(stunned ? '!' : icon)
|
||||
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
|
||||
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
|
||||
}
|
||||
|
||||
private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) {
|
||||
const width = entity.kind === 'boss' ? 92 : 34
|
||||
const height = entity.kind === 'boss' ? 8 : 7
|
||||
const y = entity.position.y + entity.radius + 8
|
||||
const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth))
|
||||
graphics.fillStyle(0x050608, 0.9)
|
||||
graphics.fillRect(entity.position.x - width / 2, y, width, height)
|
||||
graphics.fillStyle(ratio > 0.5 ? 0x4ac77d : ratio > 0.25 ? 0xf1c663 : 0xf05b4f, 1)
|
||||
graphics.fillRect(entity.position.x - width / 2, y, width * ratio, height)
|
||||
if (entity.kind === 'party' && entity.shield > 0) {
|
||||
const shieldRatio = Math.max(0, Math.min(1, entity.shield / entity.maxHealth))
|
||||
graphics.fillStyle(0x58a8ff, 0.92)
|
||||
graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
|
||||
const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record<DrawableEntity['kind'], number>
|
||||
return rank[a.kind] - rank[b.kind]
|
||||
}
|
||||
|
||||
function entityColor(entity: DrawableEntity): string {
|
||||
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color
|
||||
if (entity.kind === 'hostileAdd') return '#f0b84f'
|
||||
return IWT2_CLASS_METADATA[entity.classId].color
|
||||
}
|
||||
|
||||
function entityIcon(entity: DrawableEntity): string {
|
||||
if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].icon
|
||||
if (entity.kind === 'hostileAdd') return 'v'
|
||||
return IWT2_CLASS_METADATA[entity.classId].icon
|
||||
}
|
||||
|
||||
function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) {
|
||||
const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds)))
|
||||
const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34
|
||||
graphics.fillStyle(0xff7a2f, alpha)
|
||||
graphics.lineStyle(2, 0xffc15a, 0.55)
|
||||
graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
||||
graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius)
|
||||
}
|
||||
|
||||
function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2ArenaIndicator) {
|
||||
const color = Number.parseInt(indicator.color.slice(1), 16)
|
||||
const active = indicator.phase === 'active'
|
||||
graphics.lineStyle(active ? 8 : 5, color, indicator.lineAlpha ?? (active ? 0.76 : 0.5))
|
||||
graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09))
|
||||
|
||||
if (indicator.kind === 'lane') {
|
||||
const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width)
|
||||
graphics.fillPoints(points, true)
|
||||
graphics.strokePoints(points, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (indicator.kind === 'circle') {
|
||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.radius)
|
||||
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.radius)
|
||||
return
|
||||
}
|
||||
|
||||
if (indicator.kind === 'cone') {
|
||||
const points = coneDangerPolygon(
|
||||
indicator.origin,
|
||||
indicator.direction,
|
||||
indicator.range,
|
||||
indicator.angleRadians,
|
||||
)
|
||||
graphics.fillPoints(points, true)
|
||||
graphics.strokePoints(points, true)
|
||||
return
|
||||
}
|
||||
|
||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
|
||||
graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius)
|
||||
graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6)
|
||||
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
|
||||
}
|
||||
|
||||
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
|
||||
const dx = end.x - start.x
|
||||
const dy = end.y - start.y
|
||||
const length = Math.max(1, Math.hypot(dx, dy))
|
||||
const normal = {
|
||||
x: (-dy / length) * halfWidth,
|
||||
y: (dx / length) * halfWidth,
|
||||
}
|
||||
return [
|
||||
new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y),
|
||||
new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y),
|
||||
new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y),
|
||||
new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y),
|
||||
]
|
||||
}
|
||||
|
||||
function coneDangerPolygon(
|
||||
origin: Iwt2Vec2,
|
||||
direction: Iwt2Vec2,
|
||||
range: number,
|
||||
angleRadians: number,
|
||||
) {
|
||||
const baseAngle = Math.atan2(direction.y, direction.x)
|
||||
const halfAngle = angleRadians / 2
|
||||
const points = [new Phaser.Math.Vector2(origin.x, origin.y)]
|
||||
const segmentCount = 14
|
||||
for (let index = 0; index <= segmentCount; index += 1) {
|
||||
const t = index / segmentCount
|
||||
const angle = baseAngle - halfAngle + angleRadians * t
|
||||
points.push(new Phaser.Math.Vector2(
|
||||
origin.x + Math.cos(angle) * range,
|
||||
origin.y + Math.sin(angle) * range,
|
||||
))
|
||||
}
|
||||
return points
|
||||
}
|
||||
@@ -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,799 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import type { MovementVector } from '../../../input'
|
||||
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
|
||||
import {
|
||||
useDualScreen,
|
||||
useDualScreenPublisher,
|
||||
type DualScreenCombatState,
|
||||
} from '../../../dualScreen'
|
||||
import type { PartyMember, Role, Spell } from '../../../game'
|
||||
import {
|
||||
createInitialIwt2ArenaState,
|
||||
tickIwt2Arena,
|
||||
type Iwt2ArenaState,
|
||||
} from '../sim/arenaState'
|
||||
import type { Iwt2EntityId } from '../sim'
|
||||
import { castIwt2HealerAbility } from '../sim'
|
||||
import { PhaserArena } from '../render/PhaserArena'
|
||||
import {
|
||||
recordIwt2BossKill,
|
||||
type Iwt2Save,
|
||||
} from '../save/iwt2Repository'
|
||||
import { AbilityBar } from '../components/AbilityBar'
|
||||
import { BossHud } from '../components/BossHud'
|
||||
import { PartyFrames } from '../components/PartyFrames'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
||||
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import type {
|
||||
Iwt2RoguelikeContentType,
|
||||
Iwt2RoguelikeOpponentDebuffId,
|
||||
Iwt2RoguelikeSelfBuffId,
|
||||
Iwt2RoguelikeVariant,
|
||||
} from '../content/roguelike'
|
||||
|
||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||
type OverlayAction = 'primary' | 'menu'
|
||||
type OverlayNavEntry = {
|
||||
action: OverlayAction
|
||||
row: number
|
||||
}
|
||||
|
||||
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||
{ action: 'primary', row: 0 },
|
||||
{ action: 'menu', row: 1 },
|
||||
]
|
||||
|
||||
type BossArenaScreenProps = {
|
||||
bossId: Iwt2BossId
|
||||
bossIds?: Iwt2BossId[]
|
||||
modeLabel?: string
|
||||
save: Iwt2Save
|
||||
onBack: () => void
|
||||
onSaveUpdated: (save: Iwt2Save) => void
|
||||
roguelikeRun?: {
|
||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||
contentType: Iwt2RoguelikeContentType
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||
onVictory: () => void
|
||||
stage: number
|
||||
variant: Iwt2RoguelikeVariant
|
||||
}
|
||||
}
|
||||
|
||||
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds))
|
||||
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null
|
||||
))
|
||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||
const stateRef = useRef(arenaState)
|
||||
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
||||
const statusRef = useRef(status)
|
||||
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
|
||||
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
|
||||
const saveRef = useRef(save)
|
||||
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
|
||||
const lastPublishTimeRef = useRef(0)
|
||||
const lastHudSignatureRef = useRef('')
|
||||
const abilityCooldownsRef = useRef<Record<string, number>>({})
|
||||
const movementRef = useMovementVectorRef(status === 'playing')
|
||||
const {
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
lastDevice,
|
||||
} = useInput()
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const activeBindings = bindings[lastDevice]
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = arenaState
|
||||
}, [arenaState])
|
||||
|
||||
useEffect(() => {
|
||||
opponentStateRef.current = opponentArenaState
|
||||
}, [opponentArenaState])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
selectedOverlayActionRef.current = selectedOverlayAction
|
||||
}, [selectedOverlayAction])
|
||||
|
||||
useEffect(() => {
|
||||
selectedPartyIdRef.current = selectedPartyId
|
||||
}, [selectedPartyId])
|
||||
|
||||
useEffect(() => {
|
||||
saveRef.current = save
|
||||
}, [save])
|
||||
|
||||
const resetArena = useCallback(() => {
|
||||
const next = createInitialIwt2ArenaState(bossId, bossIds)
|
||||
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds) : null
|
||||
recordedKillIdsRef.current = new Set()
|
||||
abilityCooldownsRef.current = {}
|
||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||
stateRef.current = next
|
||||
opponentStateRef.current = nextOpponentState
|
||||
setArenaState(next)
|
||||
setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns({})
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus('playing')
|
||||
}, [bossId, bossIds, pvpRoguelike])
|
||||
|
||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||
setSelectedOverlayAction('primary')
|
||||
setStatus(nextStatus)
|
||||
}, [])
|
||||
|
||||
const abilities = useMemo(
|
||||
() => applyRoguelikeModifiers(
|
||||
abilitiesForHealer(save.character.healerStyle),
|
||||
roguelikeRun?.buffs ?? [],
|
||||
roguelikeRun?.debuffs ?? [],
|
||||
),
|
||||
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
||||
)
|
||||
|
||||
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||
if (statusRef.current !== 'playing') return
|
||||
if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return
|
||||
const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current)
|
||||
if (!result.cast) return
|
||||
|
||||
const nextCooldowns = {
|
||||
...abilityCooldownsRef.current,
|
||||
[ability.id]: ability.cooldownSeconds,
|
||||
}
|
||||
abilityCooldownsRef.current = nextCooldowns
|
||||
stateRef.current = result.state
|
||||
lastHudSignatureRef.current = arenaHudSignature(result.state)
|
||||
setAbilityCooldowns(nextCooldowns)
|
||||
setArenaState(result.state)
|
||||
}, [])
|
||||
|
||||
const moveOverlaySelection = useCallback((action: string) => {
|
||||
setSelectedOverlayAction((current) => {
|
||||
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
|
||||
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
|
||||
if (entry.action === current) return false
|
||||
if (action === 'navigateUp') return entry.row < active.row
|
||||
if (action === 'navigateDown') return entry.row > active.row
|
||||
return false
|
||||
})
|
||||
if (candidates.length === 0) return current
|
||||
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
||||
return candidates[0]?.action ?? current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
||||
if (overlayAction === 'menu') {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (statusRef.current === 'paused') {
|
||||
setStatus('playing')
|
||||
return
|
||||
}
|
||||
if (statusRef.current === 'victory' && roguelikeRun) {
|
||||
roguelikeRun.onVictory()
|
||||
return
|
||||
}
|
||||
resetArena()
|
||||
}, [onBack, resetArena, roguelikeRun])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||
if (action.startsWith('navigate')) {
|
||||
moveOverlaySelection(action)
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
activateOverlayAction()
|
||||
return
|
||||
}
|
||||
if (action === 'back') {
|
||||
if (statusRef.current === 'paused') setStatus('playing')
|
||||
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
|
||||
let nextOpponentState = opponentStateRef.current
|
||||
if (pvpRoguelike && nextOpponentState) {
|
||||
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
|
||||
nextOpponentState = tickIwt2Arena(
|
||||
nextOpponentState,
|
||||
{
|
||||
moveX: opponentMovement.x,
|
||||
moveY: opponentMovement.y,
|
||||
},
|
||||
dtSeconds,
|
||||
)
|
||||
opponentStateRef.current = nextOpponentState
|
||||
}
|
||||
|
||||
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
|
||||
if (newlyDefeatedBosses.length > 0) {
|
||||
const nextRecordedIds = new Set(recordedKillIdsRef.current)
|
||||
let updatedSave = saveRef.current
|
||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||
nextRecordedIds.add(defeatedBoss.bossId)
|
||||
updatedSave = recordIwt2BossKill(updatedSave, defeatedBoss.bossId)
|
||||
}
|
||||
recordedKillIdsRef.current = nextRecordedIds
|
||||
saveRef.current = updatedSave
|
||||
onSaveUpdated(updatedSave)
|
||||
}
|
||||
|
||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
||||
showOverlay('victory')
|
||||
} else if (next.party.every((member) => member.health <= 0)) {
|
||||
showOverlay('defeat')
|
||||
}
|
||||
|
||||
const hudSignature = arenaHudSignature(next)
|
||||
if (
|
||||
hudSignature !== lastHudSignatureRef.current
|
||||
|| next.time - lastPublishTimeRef.current >= 0.08
|
||||
|| next.bosses.some((boss) => boss.health <= 0)
|
||||
) {
|
||||
lastHudSignatureRef.current = hudSignature
|
||||
lastPublishTimeRef.current = next.time
|
||||
setArenaState(next)
|
||||
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
|
||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||
}
|
||||
return next
|
||||
}, [onSaveUpdated, pvpRoguelike, showOverlay])
|
||||
|
||||
const targetBindings = directPartyTargeting
|
||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
||||
: undefined
|
||||
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
|
||||
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
||||
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
|
||||
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
|
||||
const overlayPrimaryLabel = status === 'paused'
|
||||
? 'Resume'
|
||||
: status === 'victory' && roguelikeRun
|
||||
? 'Choose Upgrade'
|
||||
: 'Restart'
|
||||
const overlayTitle = status === 'victory'
|
||||
? `${bossTitle} Down`
|
||||
: status === 'defeat'
|
||||
? 'Party Defeated'
|
||||
: 'Arena Paused'
|
||||
const overlayEyebrow = status === 'victory'
|
||||
? 'Hunt Complete'
|
||||
: status === 'defeat'
|
||||
? 'Recovery'
|
||||
: 'Paused'
|
||||
const overlayTone = status === 'victory'
|
||||
? 'is-victory'
|
||||
: status === 'defeat'
|
||||
? 'is-defeat'
|
||||
: 'is-paused'
|
||||
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
|
||||
const pauseCopy = roguelikeRun?.variant === 'pvp'
|
||||
? undefined
|
||||
: 'Combat is stopped. Resume the fight or leave the current run.'
|
||||
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
|
||||
? 'Leave'
|
||||
: roguelikeRun
|
||||
? 'Leave Roguelike'
|
||||
: `Leave ${modeLabel ?? 'Arena'}`
|
||||
const dualScreenState = useMemo(
|
||||
() => buildIwt2DualScreenCombatState({
|
||||
abilities,
|
||||
arenaState,
|
||||
bindings: activeBindings,
|
||||
cooldowns: abilityCooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel: modeLabel ?? 'Arena',
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
}),
|
||||
[
|
||||
abilities,
|
||||
activeBindings,
|
||||
arenaState,
|
||||
abilityCooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel,
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
],
|
||||
)
|
||||
|
||||
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||
|
||||
return (
|
||||
<main
|
||||
className="game-shell iwt2-arena-shell"
|
||||
data-combat-active={status === 'playing' ? 'true' : 'false'}
|
||||
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
|
||||
>
|
||||
<section className="iwt2-arena-layout">
|
||||
<div className="iwt2-arena-stage">
|
||||
<BossHud bosses={arenaState.bosses} />
|
||||
<PartyFrames
|
||||
controllerIconStyle={controllerIconStyle}
|
||||
onTarget={setSelectedPartyId}
|
||||
party={arenaState.party}
|
||||
selectedPartyId={selectedPartyId}
|
||||
targetBindings={targetBindings}
|
||||
/>
|
||||
|
||||
<PhaserArena
|
||||
movementRef={movementRef}
|
||||
onStep={onStep}
|
||||
selectedPartyIdRef={selectedPartyIdRef}
|
||||
stateRef={stateRef}
|
||||
/>
|
||||
{status === 'paused' && (
|
||||
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
|
||||
<div>
|
||||
<p className="eyebrow">Paused</p>
|
||||
<h2>{pauseTitle}</h2>
|
||||
{pauseCopy && <p>{pauseCopy}</p>}
|
||||
<button
|
||||
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('primary')}
|
||||
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||
type="button"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('menu')}
|
||||
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||
type="button"
|
||||
>
|
||||
{pauseLeaveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'playing' && status !== 'paused' && (
|
||||
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
|
||||
<div className="iwt2-result-panel">
|
||||
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
|
||||
<span>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
|
||||
</div>
|
||||
<p className="eyebrow">{overlayEyebrow}</p>
|
||||
<h1>{overlayTitle}</h1>
|
||||
{roguelikeRun && (
|
||||
<p className="iwt2-result-hint">
|
||||
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
|
||||
</p>
|
||||
)}
|
||||
<div className="iwt2-result-summary" aria-label="Arena result summary">
|
||||
<span>
|
||||
<strong>{formatArenaTime(arenaState.time)}</strong>
|
||||
Clear
|
||||
</span>
|
||||
<span>
|
||||
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
|
||||
Standing
|
||||
</span>
|
||||
<span>
|
||||
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
|
||||
Bosses
|
||||
</span>
|
||||
<span>
|
||||
<strong>{Math.round(totalPartyDamage)}</strong>
|
||||
Damage
|
||||
</span>
|
||||
</div>
|
||||
{status === 'victory' && (
|
||||
<div className="iwt2-result-reward">
|
||||
<span>+{arenaState.bosses.length * 125} XP</span>
|
||||
<span>{arenaState.bosses.length} carves</span>
|
||||
<span>Log +{arenaState.bosses.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="iwt2-overlay-actions">
|
||||
<button
|
||||
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('primary')}
|
||||
type="button"
|
||||
>
|
||||
{overlayPrimaryLabel}
|
||||
</button>
|
||||
<button
|
||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||
onClick={() => activateOverlayAction('menu')}
|
||||
type="button"
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
{lastDevice === 'controller' && (
|
||||
<small className="iwt2-result-hint">D-pad selects, A confirms, 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.bosses.map((boss) => [
|
||||
boss.id,
|
||||
Math.ceil(boss.health),
|
||||
boss.attackPhase,
|
||||
].join(':')),
|
||||
state.hostileAdds.length,
|
||||
state.hazards.length,
|
||||
...state.party.map((member) => [
|
||||
member.id,
|
||||
Math.ceil(member.health),
|
||||
Math.ceil(member.shield),
|
||||
Math.ceil(member.mana),
|
||||
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
|
||||
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
|
||||
].join(':')),
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
|
||||
return bosses
|
||||
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
|
||||
.join(' + ')
|
||||
}
|
||||
|
||||
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
|
||||
const living = state.party.filter((member) => member.health > 0)
|
||||
const targets = living.length > 0 ? living : state.party
|
||||
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
|
||||
const nextIndex = (currentIndex + direction + targets.length) % targets.length
|
||||
return targets[nextIndex]?.id ?? currentId
|
||||
}
|
||||
|
||||
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
||||
let changed = false
|
||||
const next: Record<string, number> = {}
|
||||
for (const [id, remaining] of Object.entries(cooldowns)) {
|
||||
const value = Math.max(0, remaining - dt)
|
||||
if (value !== remaining) changed = true
|
||||
if (value > 0) next[id] = value
|
||||
}
|
||||
return changed ? next : cooldowns
|
||||
}
|
||||
|
||||
function applyRoguelikeModifiers(
|
||||
abilities: Iwt2HealerAbility[],
|
||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
||||
): Iwt2HealerAbility[] {
|
||||
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
||||
return abilities.map((ability) => {
|
||||
const slot = String(ability.slot)
|
||||
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
||||
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
|
||||
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
||||
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
||||
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
||||
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
|
||||
return {
|
||||
...ability,
|
||||
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
||||
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
||||
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function countStacks(items: readonly string[], id: string) {
|
||||
return items.filter((item) => item === id).length
|
||||
}
|
||||
|
||||
function roundModifier(value: number) {
|
||||
return Math.max(0.1, Math.round(value * 100) / 100)
|
||||
}
|
||||
|
||||
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
|
||||
if (contentType === 'raid') return 'Raid'
|
||||
if (contentType === 'stadium') return 'Stadium'
|
||||
return 'Dungeon'
|
||||
}
|
||||
|
||||
function arenaPauseTitle(
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||
fallbackTitle: string,
|
||||
) {
|
||||
if (!roguelikeRun) return fallbackTitle
|
||||
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
|
||||
if (roguelikeRun.variant === 'pvp') {
|
||||
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
|
||||
}
|
||||
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
|
||||
}
|
||||
|
||||
function buildIwt2DualScreenCombatState({
|
||||
abilities,
|
||||
arenaState,
|
||||
bindings,
|
||||
cooldowns,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
modeLabel,
|
||||
opponentArenaState,
|
||||
playerMana,
|
||||
roguelikeRun,
|
||||
selectedPartyId,
|
||||
status,
|
||||
}: {
|
||||
abilities: Iwt2HealerAbility[]
|
||||
arenaState: Iwt2ArenaState
|
||||
bindings: DualScreenCombatState['bindings']
|
||||
cooldowns: Record<string, number>
|
||||
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
|
||||
directPartyTargeting: boolean
|
||||
modeLabel: string
|
||||
opponentArenaState: Iwt2ArenaState | null
|
||||
playerMana: number
|
||||
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
|
||||
selectedPartyId: Iwt2EntityId
|
||||
status: ArenaStatus
|
||||
}): DualScreenCombatState {
|
||||
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
|
||||
const arenaBossHealth = totalBossHealth(arenaState)
|
||||
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
|
||||
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
|
||||
? {
|
||||
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
|
||||
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
|
||||
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
|
||||
opponentArena: toDualScreenOpponentArena(opponentArenaState),
|
||||
opponentEnemyHealth: totalBossHealth(opponentArenaState),
|
||||
opponentMaxResource: opponentHealer?.maxMana ?? 100,
|
||||
opponentName: 'CPU Rival',
|
||||
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
|
||||
opponentResource: opponentHealer?.mana ?? 100,
|
||||
opponentResourceName: 'Mana',
|
||||
}
|
||||
: {}
|
||||
return {
|
||||
...pvpOpponentState,
|
||||
bindings,
|
||||
contentName: modeLabel,
|
||||
controllerIconStyle,
|
||||
difficultyName: 'IWT2',
|
||||
directPartyTargeting,
|
||||
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
|
||||
encounterCount: 1,
|
||||
encounterDescription: arenaState.boss.attackPhase,
|
||||
encounterHealth: arenaBossHealth,
|
||||
encounterIndex: 0,
|
||||
encounterIsBoss: true,
|
||||
encounterMaxHealth: arenaBossMaxHealth,
|
||||
encounterName: formatBossEncounterTitle(arenaState.bosses),
|
||||
floatingTexts: [],
|
||||
maxResource: 100,
|
||||
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
|
||||
partySize: arenaState.party.length,
|
||||
paused: status === 'paused',
|
||||
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
|
||||
resource: playerMana,
|
||||
resourceName: 'Mana',
|
||||
selectedId: selectedPartyId,
|
||||
speedMultiplier: 1,
|
||||
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
|
||||
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
|
||||
targetGroup: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function totalBossHealth(state: Iwt2ArenaState): number {
|
||||
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
|
||||
}
|
||||
|
||||
function totalBossMaxHealth(state: Iwt2ArenaState): number {
|
||||
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
|
||||
}
|
||||
|
||||
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
|
||||
return {
|
||||
bounds: state.bounds,
|
||||
bosses: state.bosses.map((boss) => {
|
||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
return {
|
||||
id: boss.id,
|
||||
name: metadata.name,
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: boss.position.x,
|
||||
y: boss.position.y,
|
||||
radius: boss.radius,
|
||||
health: boss.health,
|
||||
maxHealth: boss.maxHealth,
|
||||
}
|
||||
}),
|
||||
party: state.party.map((member) => {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
return {
|
||||
id: member.id,
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: member.position.x,
|
||||
y: member.position.y,
|
||||
radius: member.radius,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
isHealer: member.id === 'player-healer',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
return {
|
||||
bounceHeals: [],
|
||||
health: member.health,
|
||||
hotEffects: member.hotEffects.map((effect) => ({
|
||||
id: effect.id,
|
||||
label: effect.label,
|
||||
power: effect.power,
|
||||
spellId: effect.id,
|
||||
ticks: Math.ceil(effect.remainingSeconds),
|
||||
})),
|
||||
hotTicks: member.hotEffects.length,
|
||||
id: opponent ? `opponent-${member.id}` : member.id,
|
||||
maxHealth: member.maxHealth,
|
||||
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
|
||||
role: toDualScreenRole(metadata.role),
|
||||
shield: member.shield,
|
||||
}
|
||||
}
|
||||
|
||||
function opponentPartyName(name: string): string {
|
||||
if (name === 'Player Healer') return 'CPU Healer'
|
||||
return `CPU ${name.replace(' Tank', '')}`
|
||||
}
|
||||
|
||||
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
|
||||
if (role === 'tank') return 'Tank'
|
||||
if (role === 'healer') return 'Healer'
|
||||
return 'Damage'
|
||||
}
|
||||
|
||||
function toDualScreenSpell(
|
||||
ability: Iwt2HealerAbility,
|
||||
slotIndex: number,
|
||||
remaining: number,
|
||||
): Spell & { slotIndex: number; remaining: number } {
|
||||
return {
|
||||
cooldown: ability.cooldownSeconds,
|
||||
cost: ability.manaCost,
|
||||
description: 'IWT2 arena ability',
|
||||
effectType: ability.effectType,
|
||||
glyph: ability.icon,
|
||||
id: ability.id,
|
||||
key: String(ability.slot),
|
||||
kind: ability.kind,
|
||||
name: ability.name,
|
||||
power: ability.power,
|
||||
remaining,
|
||||
slotIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
|
||||
const healer = state.party.find((member) => member.id === 'player-healer')
|
||||
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
|
||||
const orbitSeconds = Math.max(1, state.time)
|
||||
return normalizedArenaMovement({
|
||||
x: Math.cos(orbitSeconds * 0.75),
|
||||
y: Math.sin(orbitSeconds * 0.75) * 0.55,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizedArenaMovement(vector: MovementVector): MovementVector {
|
||||
const magnitude = Math.hypot(vector.x, vector.y)
|
||||
if (magnitude <= 1) return vector
|
||||
return {
|
||||
x: vector.x / magnitude,
|
||||
y: vector.y / magnitude,
|
||||
}
|
||||
}
|
||||
|
||||
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
|
||||
if (debuffs.length === 0) return 'none'
|
||||
return debuffs
|
||||
.map((debuff) => debuff
|
||||
.replace('opp-', '')
|
||||
.replaceAll('-', ' '))
|
||||
.join(', ')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,754 @@
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||
import type {
|
||||
Iwt2ArenaEvent,
|
||||
Iwt2ArenaIndicator,
|
||||
Iwt2ArenaInput,
|
||||
Iwt2ArenaState,
|
||||
Iwt2BossEntityState,
|
||||
Iwt2EntityId,
|
||||
Iwt2GroundHazardState,
|
||||
Iwt2HostileAddState,
|
||||
Iwt2PartyAiRole,
|
||||
Iwt2PartyEntityId,
|
||||
Iwt2PartyEntityState,
|
||||
Iwt2ProjectileEntityState,
|
||||
Iwt2StatusState,
|
||||
} from './types'
|
||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||
import { tickBoss } from './bossAi'
|
||||
import { separateCircles } from './collision'
|
||||
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
||||
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
||||
import { applyPartyDamageInShape } from './mechanics'
|
||||
import {
|
||||
clampVec2ToArena,
|
||||
distanceVec2,
|
||||
normalizeVec2,
|
||||
scaleVec2,
|
||||
subtractVec2,
|
||||
} from './vector'
|
||||
|
||||
const DEFAULT_ARENA_WIDTH = 960
|
||||
const DEFAULT_ARENA_HEIGHT = 540
|
||||
const MAX_DT = 1 / 15
|
||||
const MAX_EVENTS = 80
|
||||
const HEALER_MANA_REGEN_PER_SECOND = 3
|
||||
const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22
|
||||
const IWT2_ARENA_BOSS_IDS: Iwt2BossId[] = ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'khezu']
|
||||
|
||||
type InitialPartyMember = {
|
||||
id: Iwt2PartyEntityId
|
||||
classId: Iwt2PlayerClassId
|
||||
aiRole: Iwt2PartyAiRole
|
||||
x: number
|
||||
y: number
|
||||
preferredOffset: { x: number; y: number }
|
||||
decisionOffset: number
|
||||
}
|
||||
|
||||
const INITIAL_PARTY: InitialPartyMember[] = [
|
||||
{ id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 },
|
||||
{ id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 },
|
||||
{ id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 },
|
||||
{ id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 },
|
||||
{ id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 },
|
||||
{ id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 },
|
||||
]
|
||||
|
||||
export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome', bossIds?: Iwt2BossId[]): Iwt2ArenaState {
|
||||
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
||||
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index))
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
time: 0,
|
||||
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||
party: INITIAL_PARTY.map(createPartyMember),
|
||||
projectiles: [],
|
||||
hostileAdds: [],
|
||||
hazards: [],
|
||||
indicators: [],
|
||||
boss: bosses[0],
|
||||
bosses,
|
||||
nextEventId: 1,
|
||||
nextProjectileId: 1,
|
||||
nextAddId: 1,
|
||||
nextHazardId: 1,
|
||||
events: [],
|
||||
}
|
||||
}
|
||||
|
||||
function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
|
||||
const remaining = IWT2_ARENA_BOSS_IDS.filter((id) => id !== primaryBossId)
|
||||
const random = remaining[Math.floor(Math.random() * remaining.length)] ?? primaryBossId
|
||||
return [primaryBossId, random]
|
||||
}
|
||||
|
||||
function createBossEntity(bossId: Iwt2BossId, index: number): Iwt2BossEntityState {
|
||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||
const position = initialBossPosition(index)
|
||||
return {
|
||||
id: bossId,
|
||||
kind: 'boss',
|
||||
bossId,
|
||||
position,
|
||||
velocity: { x: 0, y: 0 },
|
||||
facing: { x: -1, y: 0 },
|
||||
radius: bossMetadata.radius,
|
||||
health: bossMetadata.maxHealth,
|
||||
maxHealth: bossMetadata.maxHealth,
|
||||
meleeCooldownRemaining: 0.6 + index * 0.25,
|
||||
chargeCooldownRemaining: initialBossSpecialCooldown(bossId) + index * 0.7,
|
||||
chargeCount: 0,
|
||||
attackPhase: 'idle',
|
||||
phaseSecondsRemaining: 0,
|
||||
chargeStart: { ...position },
|
||||
chargeEnd: { ...position },
|
||||
chargeHitEntityIds: [],
|
||||
slamApplied: false,
|
||||
wallContactSeconds: 0,
|
||||
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
|
||||
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
||||
fireballTarget: { x: 320, y: 250 },
|
||||
birdWaveThresholdsTriggered: [],
|
||||
mechanicLanes: [],
|
||||
mechanicCircles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function initialBossPosition(index: number) {
|
||||
return {
|
||||
x: index === 0 ? 660 : 760,
|
||||
y: index === 0 ? 190 : 345,
|
||||
}
|
||||
}
|
||||
|
||||
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
||||
if (bossId === 'bulldrome') return 2
|
||||
if (bossId === 'great-jaggi') return 2.4
|
||||
if (bossId === 'khezu') return 3
|
||||
return 0
|
||||
}
|
||||
|
||||
function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
|
||||
if (bossId === 'yian-kut-ku') return 1.2
|
||||
if (bossId === 'khezu') return 1.6
|
||||
return 0
|
||||
}
|
||||
|
||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||
const step = Math.max(0, Math.min(dt, MAX_DT))
|
||||
if (step <= 0) return { ...state, events: [...state.events] }
|
||||
|
||||
const inputMove = {
|
||||
x: clampInputAxis(input.moveX),
|
||||
y: clampInputAxis(input.moveY),
|
||||
}
|
||||
const baseState = {
|
||||
...state,
|
||||
time: state.time + step,
|
||||
party: state.party.map((member) => tickPartyMember(member, state, inputMove, step)),
|
||||
events: [],
|
||||
}
|
||||
const separatedState = {
|
||||
...baseState,
|
||||
party: separatePartyFromBosses(baseState.party, baseState),
|
||||
}
|
||||
const bossResult = tickBosses(separatedState, step)
|
||||
const projectileResult = advanceProjectiles(
|
||||
[...separatedState.projectiles, ...(bossResult.projectiles ?? [])],
|
||||
bossResult.party,
|
||||
bossResult.bosses,
|
||||
bossResult.hostileAdds ?? separatedState.hostileAdds,
|
||||
bossResult.hazards ?? separatedState.hazards,
|
||||
bossResult.nextHazardId ?? state.nextHazardId,
|
||||
bossResult.nextProjectileId ?? state.nextProjectileId,
|
||||
separatedState.bounds,
|
||||
separatedState.time,
|
||||
step,
|
||||
)
|
||||
const hazardResult = tickGroundHazards({
|
||||
dt: step,
|
||||
hazards: projectileResult.hazards,
|
||||
party: projectileResult.party,
|
||||
time: separatedState.time,
|
||||
})
|
||||
const damageResult = applyPartyAttacks(
|
||||
hazardResult.party,
|
||||
projectileResult.bosses,
|
||||
projectileResult.hostileAdds,
|
||||
separatedState.time,
|
||||
projectileResult.nextProjectileId,
|
||||
)
|
||||
const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time)
|
||||
const finalParty = regeneratePartyMana(hotResult.party, step)
|
||||
const nextEvents = assignEventIds(
|
||||
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
|
||||
state.nextEventId,
|
||||
)
|
||||
return {
|
||||
...separatedState,
|
||||
boss: getPrimaryBoss(damageResult.bosses),
|
||||
bosses: damageResult.bosses,
|
||||
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
||||
party: finalParty,
|
||||
hostileAdds: damageResult.hostileAdds,
|
||||
hazards: hazardResult.hazards,
|
||||
projectiles: [...projectileResult.projectiles, ...damageResult.projectiles],
|
||||
nextProjectileId: damageResult.nextProjectileId,
|
||||
nextAddId: bossResult.nextAddId ?? state.nextAddId,
|
||||
nextHazardId: projectileResult.nextHazardId,
|
||||
nextEventId: state.nextEventId + nextEvents.length,
|
||||
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
|
||||
}
|
||||
}
|
||||
|
||||
function tickPartyHotEffects(
|
||||
party: Iwt2PartyEntityState[],
|
||||
dt: number,
|
||||
time: number,
|
||||
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const nextParty = party.map((member) => {
|
||||
if (member.hotEffects.length === 0) return member
|
||||
|
||||
let health = member.health
|
||||
const hotEffects = member.hotEffects.flatMap((effect) => {
|
||||
const remainingSeconds = effect.remainingSeconds - dt
|
||||
if (remainingSeconds <= 0) return []
|
||||
let nextTickInSeconds = effect.nextTickInSeconds - dt
|
||||
if (member.health > 0 && nextTickInSeconds <= 0) {
|
||||
const before = health
|
||||
health = Math.min(member.maxHealth, health + effect.power)
|
||||
const healed = health - before
|
||||
if (healed > 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'partyHealed',
|
||||
sourceId: 'player-healer',
|
||||
targetId: member.id,
|
||||
value: healed,
|
||||
})
|
||||
}
|
||||
nextTickInSeconds += effect.tickIntervalSeconds
|
||||
}
|
||||
return [{
|
||||
...effect,
|
||||
nextTickInSeconds,
|
||||
remainingSeconds,
|
||||
}]
|
||||
})
|
||||
|
||||
return {
|
||||
...member,
|
||||
health,
|
||||
hotEffects,
|
||||
}
|
||||
})
|
||||
return { party: nextParty, events }
|
||||
}
|
||||
|
||||
function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => {
|
||||
if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member
|
||||
return {
|
||||
...member,
|
||||
mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
|
||||
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
||||
return {
|
||||
id: initial.id,
|
||||
kind: 'party',
|
||||
classId: initial.classId,
|
||||
aiRole: initial.aiRole,
|
||||
position: { x: initial.x, y: initial.y },
|
||||
velocity: { x: 0, y: 0 },
|
||||
facing: { x: 1, y: 0 },
|
||||
radius: metadata.radius,
|
||||
health: metadata.maxHealth,
|
||||
maxHealth: metadata.maxHealth,
|
||||
shield: 0,
|
||||
mana: initial.classId === 'healer' ? 100 : 0,
|
||||
maxMana: initial.classId === 'healer' ? 100 : 0,
|
||||
damageDone: 0,
|
||||
attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35,
|
||||
castSecondsRemaining: 0,
|
||||
attackReady: false,
|
||||
status: createEmptyStatus(),
|
||||
hotEffects: [],
|
||||
preferredOffset: { ...initial.preferredOffset },
|
||||
decisionSecondsRemaining: initial.decisionOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyStatus(): Iwt2StatusState {
|
||||
return {
|
||||
stunnedSeconds: 0,
|
||||
knockedDownSeconds: 0,
|
||||
invulnerableSeconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function clampInputAxis(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(1, Math.max(-1, value))
|
||||
}
|
||||
|
||||
function tickBosses(state: Iwt2ArenaState, dt: number) {
|
||||
let nextParty = state.party
|
||||
let nextHostileAdds = state.hostileAdds
|
||||
let nextHazards = state.hazards
|
||||
let nextProjectileId = state.nextProjectileId
|
||||
let nextAddId = state.nextAddId
|
||||
let nextHazardId = state.nextHazardId
|
||||
const bosses: Iwt2BossEntityState[] = []
|
||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const indicators: Iwt2ArenaIndicator[] = []
|
||||
|
||||
for (const boss of state.bosses) {
|
||||
const bossState = {
|
||||
...state,
|
||||
boss,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
nextProjectileId,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
}
|
||||
const result = tickBoss(bossState, dt)
|
||||
bosses.push(result.boss)
|
||||
nextParty = result.party
|
||||
nextHostileAdds = result.hostileAdds ?? nextHostileAdds
|
||||
nextHazards = result.hazards ?? nextHazards
|
||||
nextProjectileId = result.nextProjectileId ?? nextProjectileId
|
||||
nextAddId = result.nextAddId ?? nextAddId
|
||||
nextHazardId = result.nextHazardId ?? nextHazardId
|
||||
projectiles.push(...(result.projectiles ?? []))
|
||||
events.push(...result.events)
|
||||
indicators.push(...result.indicators)
|
||||
}
|
||||
|
||||
return {
|
||||
bosses,
|
||||
boss: getPrimaryBoss(bosses),
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
projectiles,
|
||||
events,
|
||||
indicators,
|
||||
nextAddId,
|
||||
nextHazardId,
|
||||
nextProjectileId,
|
||||
}
|
||||
}
|
||||
|
||||
function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let position = member.position
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.health <= 0) continue
|
||||
position = separateCircles(
|
||||
{ position, radius: member.radius },
|
||||
{ position: boss.position, radius: boss.radius },
|
||||
state.bounds,
|
||||
)
|
||||
}
|
||||
return { ...member, position }
|
||||
})
|
||||
}
|
||||
|
||||
function advanceProjectiles(
|
||||
projectiles: Iwt2ProjectileEntityState[],
|
||||
party: Iwt2PartyEntityState[],
|
||||
bosses: Iwt2BossEntityState[],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
hazards: Iwt2GroundHazardState[],
|
||||
nextHazardId: number,
|
||||
nextProjectileId: number,
|
||||
bounds: Iwt2ArenaState['bounds'],
|
||||
time: number,
|
||||
dt: number,
|
||||
): {
|
||||
bosses: Iwt2BossEntityState[]
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
nextProjectileId: number
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
let nextBosses = bosses
|
||||
let nextParty = party
|
||||
let nextHostileAdds = hostileAdds
|
||||
let nextHazards = hazards
|
||||
let nextHazardIdValue = nextHazardId
|
||||
const nextProjectiles: Iwt2ProjectileEntityState[] = []
|
||||
|
||||
for (const projectile of projectiles) {
|
||||
if (projectile.owner === 'boss') {
|
||||
const result = advanceBossProjectile({
|
||||
bounds,
|
||||
dt,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
party: nextParty,
|
||||
projectile,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
nextHazards = result.hazards
|
||||
nextHazardIdValue = result.nextHazardId
|
||||
events.push(...result.events)
|
||||
if (result.projectile) nextProjectiles.push(result.projectile)
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextBosses.every((boss) => boss.health <= 0) && nextHostileAdds.every((add) => add.health <= 0)) continue
|
||||
const nextPosition = {
|
||||
x: projectile.position.x + projectile.velocity.x * dt,
|
||||
y: projectile.position.y + projectile.velocity.y * dt,
|
||||
}
|
||||
const remainingSeconds = projectile.remainingSeconds - dt
|
||||
const hitAdd = nextHostileAdds.find((add) => (
|
||||
add.health > 0
|
||||
&& distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius
|
||||
))
|
||||
if (remainingSeconds <= 0) continue
|
||||
if (hitAdd) {
|
||||
const damage = Math.min(projectile.damage, hitAdd.health)
|
||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id
|
||||
? { ...add, health: Math.max(0, add.health - damage) }
|
||||
: add)
|
||||
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: hitAdd.id,
|
||||
value: damage,
|
||||
})
|
||||
if (hitAdd.health - damage <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: hitAdd.id,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const hitBoss = nextBosses.find((boss) => (
|
||||
boss.health > 0
|
||||
&& distanceVec2(nextPosition, boss.position) <= boss.radius + projectile.radius
|
||||
))
|
||||
if (hitBoss) {
|
||||
const damage = Math.min(projectile.damage, hitBoss.health)
|
||||
nextBosses = nextBosses.map((boss) => boss.id === hitBoss.id
|
||||
? { ...boss, health: Math.max(0, boss.health - damage) }
|
||||
: boss)
|
||||
nextParty = addDamageDone(nextParty, projectile.sourceId, damage)
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: hitBoss.id,
|
||||
value: damage,
|
||||
})
|
||||
if (hitBoss.health - damage <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: projectile.sourceId,
|
||||
targetId: hitBoss.id,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
nextProjectiles.push({
|
||||
...projectile,
|
||||
position: nextPosition,
|
||||
remainingSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
bosses: nextBosses,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
nextProjectileId,
|
||||
projectiles: nextProjectiles,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
function advanceBossProjectile({
|
||||
bounds,
|
||||
dt,
|
||||
hazards,
|
||||
nextHazardId,
|
||||
party,
|
||||
projectile,
|
||||
time,
|
||||
}: {
|
||||
bounds: Iwt2ArenaState['bounds']
|
||||
dt: number
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
party: Iwt2PartyEntityState[]
|
||||
projectile: Iwt2ProjectileEntityState
|
||||
time: number
|
||||
}): {
|
||||
party: Iwt2PartyEntityState[]
|
||||
hazards: Iwt2GroundHazardState[]
|
||||
nextHazardId: number
|
||||
projectile?: Iwt2ProjectileEntityState
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const remainingSeconds = projectile.remainingSeconds - dt
|
||||
if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events }
|
||||
|
||||
let nextParty = party
|
||||
let nextHazards = hazards
|
||||
let nextHazardIdValue = nextHazardId
|
||||
let bounced = false
|
||||
const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt)
|
||||
const canBounce = bounceCooldownSeconds <= 0
|
||||
let velocity = { ...projectile.velocity }
|
||||
let position = {
|
||||
x: projectile.position.x + projectile.velocity.x * dt,
|
||||
y: projectile.position.y + projectile.velocity.y * dt,
|
||||
}
|
||||
|
||||
if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) {
|
||||
velocity = { ...velocity, x: -velocity.x }
|
||||
position = clampVec2ToArena(position, projectile.radius, bounds)
|
||||
bounced = true
|
||||
}
|
||||
if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) {
|
||||
velocity = { ...velocity, y: -velocity.y }
|
||||
position = clampVec2ToArena(position, projectile.radius, bounds)
|
||||
bounced = true
|
||||
}
|
||||
|
||||
const hitMember = canBounce
|
||||
? nextParty.find((member) => (
|
||||
member.health > 0
|
||||
&& distanceVec2(position, member.position) <= projectile.radius + member.radius
|
||||
))
|
||||
: undefined
|
||||
if (hitMember) {
|
||||
const result = applyPartyDamageInShape(nextParty, {
|
||||
kind: 'circle',
|
||||
position: hitMember.position,
|
||||
radius: hitMember.radius + projectile.radius,
|
||||
}, {
|
||||
damage: projectile.damage,
|
||||
sourceId: projectile.sourceId,
|
||||
time,
|
||||
})
|
||||
nextParty = result.party
|
||||
events.push(...result.events)
|
||||
const away = normalizeVec2(subtractVec2(position, hitMember.position))
|
||||
const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1))
|
||||
const direction = away.x === 0 && away.y === 0 ? fallback : away
|
||||
const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y))
|
||||
velocity = scaleVec2(direction, speed)
|
||||
position = clampVec2ToArena({
|
||||
x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2),
|
||||
y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2),
|
||||
}, projectile.radius, bounds)
|
||||
bounced = true
|
||||
}
|
||||
|
||||
if (bounced) {
|
||||
const puddle = addFirePuddle({
|
||||
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
||||
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
position: hitMember?.position ?? position,
|
||||
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
||||
sourceId: projectile.sourceId,
|
||||
time,
|
||||
})
|
||||
nextHazards = puddle.hazards
|
||||
nextHazardIdValue = puddle.nextHazardId
|
||||
}
|
||||
|
||||
const bouncesRemaining = bounced
|
||||
? (projectile.bouncesRemaining ?? 0) - 1
|
||||
: projectile.bouncesRemaining
|
||||
if (bouncesRemaining !== undefined && bouncesRemaining < 0) {
|
||||
return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events }
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
hazards: nextHazards,
|
||||
nextHazardId: nextHazardIdValue,
|
||||
party: nextParty,
|
||||
projectile: {
|
||||
...projectile,
|
||||
bouncesRemaining,
|
||||
bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds,
|
||||
position,
|
||||
remainingSeconds,
|
||||
velocity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function applyPartyAttacks(
|
||||
party: Iwt2PartyEntityState[],
|
||||
bosses: Iwt2BossEntityState[],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
time: number,
|
||||
nextProjectileId: number,
|
||||
): {
|
||||
bosses: Iwt2BossEntityState[]
|
||||
party: Iwt2PartyEntityState[]
|
||||
hostileAdds: Iwt2HostileAddState[]
|
||||
projectiles: Iwt2ProjectileEntityState[]
|
||||
nextProjectileId: number
|
||||
events: Iwt2ArenaEvent[]
|
||||
} {
|
||||
const events: Iwt2ArenaEvent[] = []
|
||||
const projectiles: Iwt2ProjectileEntityState[] = []
|
||||
let nextBosses = bosses
|
||||
let nextHostileAdds = hostileAdds
|
||||
let projectileId = nextProjectileId
|
||||
const nextParty = party.map((member) => {
|
||||
const target = getPriorityAttackTarget(member, nextBosses, nextHostileAdds)
|
||||
if (!target) return member
|
||||
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
if (metadata.projectileSpeed > 0) {
|
||||
if (!member.attackReady) return member
|
||||
const direction = normalizeVec2(subtractVec2(target.position, member.position))
|
||||
projectiles.push({
|
||||
id: `projectile-${projectileId}`,
|
||||
sourceId: member.id,
|
||||
owner: 'party',
|
||||
classId: member.classId,
|
||||
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
|
||||
color: metadata.accentColor,
|
||||
position: {
|
||||
x: member.position.x + direction.x * (member.radius + 4),
|
||||
y: member.position.y + direction.y * (member.radius + 4),
|
||||
},
|
||||
velocity: scaleVec2(direction, metadata.projectileSpeed),
|
||||
radius: member.classId === 'mage' ? 7 : 4,
|
||||
damage: metadata.attackDamage,
|
||||
remainingSeconds: 1.2,
|
||||
})
|
||||
projectileId += 1
|
||||
return {
|
||||
...member,
|
||||
attackCooldownRemaining: metadata.attackCooldown,
|
||||
attackReady: false,
|
||||
}
|
||||
}
|
||||
if (member.attackCooldownRemaining > 0) return member
|
||||
const damage = Math.min(metadata.attackDamage, target.health)
|
||||
if (target.kind === 'boss') {
|
||||
nextBosses = nextBosses.map((boss) => boss.id === target.id
|
||||
? { ...boss, health: Math.max(0, boss.health - damage) }
|
||||
: boss)
|
||||
} else {
|
||||
nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id
|
||||
? { ...add, health: Math.max(0, add.health - damage) }
|
||||
: add)
|
||||
}
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'bossDamaged',
|
||||
sourceId: member.id,
|
||||
targetId: target.id,
|
||||
value: damage,
|
||||
})
|
||||
if (target.health - damage <= 0) {
|
||||
events.push({
|
||||
id: 0,
|
||||
time,
|
||||
type: 'entityDefeated',
|
||||
sourceId: member.id,
|
||||
targetId: target.id,
|
||||
})
|
||||
return {
|
||||
...member,
|
||||
damageDone: member.damageDone + damage,
|
||||
attackCooldownRemaining: metadata.attackCooldown,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...member,
|
||||
damageDone: member.damageDone + damage,
|
||||
attackCooldownRemaining: metadata.attackCooldown,
|
||||
}
|
||||
})
|
||||
return {
|
||||
bosses: nextBosses,
|
||||
party: nextParty,
|
||||
hostileAdds: nextHostileAdds.filter((add) => add.health > 0),
|
||||
projectiles,
|
||||
nextProjectileId: projectileId,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityAttackTarget(
|
||||
member: Iwt2PartyEntityState,
|
||||
bosses: Iwt2BossEntityState[],
|
||||
hostileAdds: Iwt2HostileAddState[],
|
||||
): (Iwt2BossEntityState | Iwt2HostileAddState) | undefined {
|
||||
if (member.health <= 0) return undefined
|
||||
const livingAdds = hostileAdds.filter((add) => add.health > 0)
|
||||
if (livingAdds.length > 0) {
|
||||
return livingAdds.reduce((best, add) => (
|
||||
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
||||
), livingAdds[0])
|
||||
}
|
||||
const livingBosses = bosses.filter((boss) => boss.health > 0)
|
||||
if (livingBosses.length === 0) return undefined
|
||||
return livingBosses.reduce((best, boss) => (
|
||||
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
||||
), livingBosses[0])
|
||||
}
|
||||
|
||||
function getPrimaryBoss(bosses: Iwt2BossEntityState[]): Iwt2BossEntityState {
|
||||
return bosses.find((boss) => boss.health > 0) ?? bosses[0]
|
||||
}
|
||||
|
||||
function addDamageDone(
|
||||
party: Iwt2PartyEntityState[],
|
||||
sourceId: Iwt2EntityId,
|
||||
damage: number,
|
||||
): Iwt2PartyEntityState[] {
|
||||
return party.map((member) => member.id === sourceId
|
||||
? { ...member, damageDone: member.damageDone + damage }
|
||||
: member)
|
||||
}
|
||||
|
||||
function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] {
|
||||
return events.map((event, index) => ({ ...event, id: firstId + index }))
|
||||
}
|
||||
@@ -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, bossIds?: Iwt2BossId[]): Iwt2ArenaState {
|
||||
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds))
|
||||
}
|
||||
|
||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||
return decorateArenaState(tickCoreIwt2Arena(state, input, dt))
|
||||
}
|
||||
|
||||
export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState {
|
||||
return {
|
||||
...state,
|
||||
arena: { ...state.bounds },
|
||||
entities: [
|
||||
...state.party.map(toArenaEntity),
|
||||
...state.hostileAdds.map(toHostileAddArenaEntity),
|
||||
...state.bosses.map(toBossArenaEntity),
|
||||
],
|
||||
telegraphs: createTelegraphs(state.indicators),
|
||||
}
|
||||
}
|
||||
|
||||
function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity {
|
||||
return {
|
||||
id: add.id,
|
||||
kind: 'hostileAdd',
|
||||
icon: 'v',
|
||||
color: '#f0b84f',
|
||||
x: add.position.x,
|
||||
y: add.position.y,
|
||||
radius: add.radius,
|
||||
health: add.health,
|
||||
maxHealth: add.maxHealth,
|
||||
stunnedFor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity {
|
||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||
return {
|
||||
id: member.id,
|
||||
kind: member.aiRole === 'player' ? 'player' : 'party',
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: member.position.x,
|
||||
y: member.position.y,
|
||||
radius: member.radius,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds),
|
||||
}
|
||||
}
|
||||
|
||||
function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity {
|
||||
const metadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
return {
|
||||
id: boss.id,
|
||||
kind: 'boss',
|
||||
icon: metadata.icon,
|
||||
color: metadata.color,
|
||||
x: boss.position.x,
|
||||
y: boss.position.y,
|
||||
radius: boss.radius,
|
||||
health: boss.health,
|
||||
maxHealth: boss.maxHealth,
|
||||
stunnedFor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] {
|
||||
return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => {
|
||||
if (indicator.kind === 'lane') {
|
||||
return [{
|
||||
active: indicator.phase === 'active',
|
||||
height: indicator.width * 2,
|
||||
kind: 'charge' as const,
|
||||
width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)),
|
||||
x: Math.min(indicator.start.x, indicator.end.x),
|
||||
y: Math.min(indicator.start.y, indicator.end.y) - indicator.width,
|
||||
}]
|
||||
}
|
||||
if (indicator.kind === 'circle') {
|
||||
return [{
|
||||
active: indicator.phase === 'active',
|
||||
kind: 'slam' as const,
|
||||
radius: indicator.radius,
|
||||
x: indicator.position.x,
|
||||
y: indicator.position.y,
|
||||
}]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
@@ -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,294 @@
|
||||
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
|
||||
const PARTY_ARRIVAL_RADIUS = 3.5
|
||||
|
||||
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, attackTarget.radius)
|
||||
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 ?? getPrimaryBoss(state).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)
|
||||
const maxDistance = metadata.moveSpeed * dt
|
||||
const distanceToDesired = distanceVec2(member.position, desired)
|
||||
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
|
||||
const position = movingToDesired
|
||||
? clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
||||
: member.position
|
||||
const velocity = movingToDesired
|
||||
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
||||
: { x: 0, y: 0 }
|
||||
return {
|
||||
...member,
|
||||
position,
|
||||
velocity,
|
||||
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
|
||||
attackCooldownRemaining,
|
||||
castSecondsRemaining: 0,
|
||||
attackReady: false,
|
||||
status,
|
||||
decisionSecondsRemaining: decisionSecondsRemaining <= 0
|
||||
? nextDecisionInterval(member)
|
||||
: decisionSecondsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): 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 + targetRadius
|
||||
}
|
||||
|
||||
function getPartyDesiredPosition(
|
||||
member: Iwt2PartyEntityState,
|
||||
state: Iwt2ArenaState,
|
||||
): Iwt2Vec2 {
|
||||
const drift = decisionDrift(member, state.time)
|
||||
const attackTarget = getPriorityAttackTarget(member, state)
|
||||
const anchor = attackTarget?.position ?? getPrimaryBoss(state).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 {
|
||||
const boss = getPrimaryBoss(state)
|
||||
if (member.aiRole !== 'tank' || !CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return null
|
||||
|
||||
const center = arenaCenter(state)
|
||||
const bossCenterDistance = distanceVec2(boss.position, center)
|
||||
const nearWall = isBossNearWall(boss, state)
|
||||
if (!nearWall && bossCenterDistance < CENTER_LEASH_START_DISTANCE) return null
|
||||
|
||||
const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
|
||||
const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
|
||||
x: boss.position.x < center.x ? 1 : -1,
|
||||
y: boss.position.y < center.y ? 0.35 : -0.35,
|
||||
})
|
||||
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
|
||||
return clampVec2ToArena(
|
||||
addVec2(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(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): boolean {
|
||||
const margin = boss.radius + CENTER_LEASH_WALL_MARGIN
|
||||
return (
|
||||
boss.position.x <= margin
|
||||
|| boss.position.x >= state.bounds.width - margin
|
||||
|| boss.position.y <= margin
|
||||
|| boss.position.y >= state.bounds.height - margin
|
||||
)
|
||||
}
|
||||
|
||||
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
||||
const hazardEscape = getHazardAvoidancePosition(member, state)
|
||||
if (hazardEscape) {
|
||||
return hazardEscape
|
||||
}
|
||||
|
||||
for (const boss of state.bosses) {
|
||||
if (boss.health <= 0) continue
|
||||
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, boss)
|
||||
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, getPrimaryBoss(state).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])
|
||||
}
|
||||
const livingBosses = state.bosses.filter((boss) => boss.health > 0)
|
||||
if (livingBosses.length === 0) return undefined
|
||||
return livingBosses.reduce((best, boss) => (
|
||||
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
||||
), livingBosses[0])
|
||||
}
|
||||
|
||||
function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['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 getPrimaryBoss(state: Iwt2ArenaState) {
|
||||
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
|
||||
}
|
||||
|
||||
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,276 @@
|
||||
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
|
||||
bosses: 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