Compare commits

...
13 Commits
Author SHA1 Message Date
Warren H e9ee7bcd70 Android build v1.1.40 2026-07-09 23:37:31 -04:00
Warren H 90930b71aa Android build v1.1.39 2026-07-09 22:07:35 -04:00
Warren H 4766a34cd3 Android build v1.1.38 2026-07-09 21:57:51 -04:00
Warren H 8ffa6db317 Android build v1.1.37 2026-07-09 19:21:51 -04:00
Warren H 1fa1c8c070 Android build v1.1.36 2026-07-08 23:33:57 -04:00
Warren H 802dadc7f3 Android build v1.1.35 2026-07-08 22:30:44 -04:00
Warren H d61cee55ed Android build v1.1.34 2026-07-07 23:25:40 -04:00
Warren H 679900e7f5 Android build v1.1.33 2026-07-07 16:41:45 -04:00
Warren H ff64c6ddcc Android build v1.1.32 2026-07-07 15:07:14 -04:00
Warren H a6cca2810c Android build v1.1.31 2026-07-06 23:49:14 -04:00
Warren H f48e6130b8 Android build v1.1.30 2026-07-06 15:35:18 -04:00
Warren H 61de17d63e Android build v1.1.29 2026-07-06 15:31:42 -04:00
Warren H 5629041879 Android build v1.1.29 2026-07-06 15:30:04 -04:00
51 changed files with 3895 additions and 849 deletions
+4
View File
@@ -15,6 +15,9 @@
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
- Focus state must always be visible and predictable when controller navigation is active.
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
- Controller navigation fixes must not break live combat bindings. When changing shared input routing, verify that combat-only actions still win during active combat: movement, ability casts, party targeting, pause, and overlay navigation must all work on AYN Thor controls.
- `data-game-nav-active` and other custom navigation markers may coexist with live combat HUDs. Do not treat those markers as menu mode during active combat unless a pause/dialog/result overlay is visible.
- Before considering any controller input change complete, test both states: no-overlay combat and menu/dialog navigation. No-overlay combat must include at least one successful ability cast from a controller binding and continuous analog movement on the AYN Thor control scheme.
## Architecture Requirements
@@ -34,6 +37,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 combat must preserve AYN Thor face/shoulder button ability bindings even when party frames, HUD controls, or target rails use custom controller navigation state. Menu navigation priority must never swallow ability inputs while `data-combat-active="true"` and no overlay is open.
- 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.
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
+31 -3
View File
@@ -54,12 +54,18 @@ code updates to be a Git pull plus app restart.
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
Repository:
Repository URL:
```text
https://git.whoagland.com/phenom/i-want-to-heal.git
```
Local Gitea repository path on the same TrueNAS host:
```text
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
```
TrueNAS paths:
```text
@@ -67,12 +73,15 @@ TrueNAS paths:
/mnt/usbssds/apps/iwanttoheal/data
```
Create the app directory and clone the repo:
Create the app directory and clone the repo. When Gitea is hosted on the same
TrueNAS box, prefer the local repository path instead of HTTPS. This avoids
Git-over-HTTPS TLS failures while downloading large pack files.
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
cd /mnt/usbssds/apps/iwanttoheal
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git app
```
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
@@ -101,6 +110,25 @@ cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
If setting up a different app hosted by the same Gitea instance, first find its
bare repository path:
```sh
sudo find /mnt -type d -name "REPO-NAME.git" -prune -print 2>/dev/null
```
Then substitute that path in the `safe.directory` and `git clone` commands:
```sh
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git app
sudo chown -R truenas_admin:truenas_admin app
```
`safe.directory` is a Git trust exception for the Gitea-owned bare repository.
It is needed because Gitea's repository files are owned by the app/container
user, not by `truenas_admin`.
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 108
versionName "1.1.28"
versionCode 121
versionName "1.1.40"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+4
View File
@@ -104,6 +104,10 @@ npm run release:cli -- --release-only 1.1.26
## Step 5: Update TrueNAS
The TrueNAS app checkout should be cloned from the local Gitea bare repository
path when Gitea runs on the same TrueNAS host. After that setup, normal updates
still use `git pull`; the remote is local filesystem storage instead of HTTPS.
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+779
View File
@@ -0,0 +1,779 @@
import { spawn } from 'node:child_process'
import { createServer } from 'vite'
const DEFAULT_WORKERS = Math.max(1, Math.min(8, Number.parseInt(process.env.IWT2_SIM_WORKERS ?? '8', 10)))
const DEFAULT_MAX_SECONDS = Math.max(30, Number.parseFloat(process.env.IWT2_SIM_SECONDS ?? '180'))
const DEFAULT_DT = 1 / 30
const DEFAULT_TICKS_PER_WORKER_SECOND = 45000
const ESTIMATE_STARTUP_SECONDS = 0.8
const PVP_BOSS_HEALTH_MULTIPLIER = 0.7
const BOSS_HEALTH_PER_STAGE = 0.1
const ARENA_BOUNDS = { width: 960, height: 540 }
const HEALER_STYLES = ['dawnweaver', 'lifebinder', 'runesage']
const BOSS_IDS = [
'bulldrome',
'yian-kut-ku',
'great-jaggi',
'khezu',
'rathian',
'barroth',
'tobi-kadachi',
'rimebastion',
'ember-mantis-duelist',
'cinderback-ricochet',
'obsidian-ram-golem',
'stormcoil-wyrm',
'venom-orchid-hydra',
'sandglass-scorpion',
'crystal-bat-matriarch',
'hollowcrown-revenant',
]
const activeChildren = new Set()
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
for (const child of activeChildren) {
child.kill(signal)
}
process.exit(signal === 'SIGINT' ? 130 : 143)
})
}
const args = parseArgs(process.argv.slice(2))
if (args.has('--help')) {
printHelp()
process.exit(0)
}
const config = {
bossCount: parsePositiveInt(args.get('--boss-count') ?? '2', 2),
bossDamagePercent: parsePositiveNumber(args.get('--boss-damage-percent') ?? '100', 100),
bossFilter: parseList(args.get('--bosses') ?? 'all'),
bossHpPercent: parsePositiveNumber(args.get('--boss-hp-percent') ?? '100', 100),
classes: parseList(args.get('--classes') ?? args.get('--class') ?? 'all'),
estimateOnly: args.has('--estimate-only'),
gearLevel: parseNonNegativeInt(args.get('--gear-level') ?? args.get('--gear') ?? '5', 5),
maxSeconds: Number.parseFloat(args.get('--seconds') ?? String(DEFAULT_MAX_SECONDS)),
repeats: parsePositiveInt(args.get('--repeats') ?? '1', 1),
requiredBosses: parseList(args.get('--required-bosses') ?? args.get('--include-bosses') ?? 'none'),
stages: parseStages(args.get('--stages') ?? '1,2'),
top: parsePositiveInt(args.get('--top') ?? '24', 24),
workers: Number.parseInt(args.get('--workers') ?? String(DEFAULT_WORKERS), 10),
}
if (args.has('--worker')) {
await runWorker({
bossCount: config.bossCount,
bossDamagePercent: config.bossDamagePercent,
bossFilter: config.bossFilter,
bossHpPercent: config.bossHpPercent,
classes: config.classes,
gearLevel: config.gearLevel,
repeats: config.repeats,
requiredBosses: config.requiredBosses,
shardIndex: Number.parseInt(args.get('--shard-index') ?? '0', 10),
shardCount: Number.parseInt(args.get('--shard-count') ?? '1', 10),
maxSeconds: config.maxSeconds,
stages: config.stages,
})
} else {
await runMain(config)
}
async function runMain({
bossCount,
bossDamagePercent,
bossFilter,
bossHpPercent,
classes,
estimateOnly,
gearLevel,
maxSeconds,
repeats,
requiredBosses,
stages,
top,
workers,
}) {
const safeWorkers = Math.max(1, Math.floor(workers))
const estimate = estimateRun({
bossCount,
bossFilter,
classes,
maxSeconds,
repeats,
requiredBosses,
stages,
workers: safeWorkers,
})
if (estimateOnly) {
console.log(JSON.stringify({
config: {
bossCount,
bosses: bossFilter,
classes,
repeats,
requiredBosses,
seconds: maxSeconds,
stages,
workers: safeWorkers,
},
estimate,
}, null, 2))
return
}
const startedAt = Date.now()
const childResults = await Promise.all(Array.from({ length: safeWorkers }, (_, shardIndex) => (
runChild({
bossCount,
bossDamagePercent,
bossFilter,
bossHpPercent,
classes,
gearLevel,
repeats,
requiredBosses,
shardIndex,
shardCount: safeWorkers,
maxSeconds,
stages,
})
)))
const results = childResults.flat()
const elapsedSeconds = round((Date.now() - startedAt) / 1000)
const summary = summarizeResults(results)
console.log(JSON.stringify({
config: {
bossCount,
bossDamagePercent,
bossHpPercent,
bosses: bossFilter,
classes,
gearLevel,
elapsedSeconds,
estimate,
orderedTrials: results.length,
repeats,
requiredBosses,
seconds: maxSeconds,
stages,
workers: safeWorkers,
},
hardestCombos: summary.hardestCombos.slice(0, top),
hardestStageCombos: summary.hardestStageCombos.slice(0, top),
topMechanics: summary.topMechanics,
weakestSingleBosses: summary.weakestSingleBosses.slice(0, 16),
}, null, 2))
}
function runChild({
bossCount,
bossDamagePercent,
bossFilter,
bossHpPercent,
classes,
gearLevel,
repeats,
requiredBosses,
shardIndex,
shardCount,
maxSeconds,
stages,
}) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [
new URL(import.meta.url).pathname,
'--worker',
'--boss-count',
String(bossCount),
'--boss-damage-percent',
String(bossDamagePercent),
'--bosses',
bossFilter.join(','),
'--boss-hp-percent',
String(bossHpPercent),
'--classes',
classes.join(','),
'--gear-level',
String(gearLevel),
'--repeats',
String(repeats),
'--required-bosses',
requiredBosses.join(','),
'--shard-index',
String(shardIndex),
'--shard-count',
String(shardCount),
'--seconds',
String(maxSeconds),
'--stages',
stages.join(','),
], {
cwd: process.cwd(),
env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' },
stdio: ['ignore', 'pipe', 'pipe'],
})
activeChildren.add(child)
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += chunk
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (code) => {
activeChildren.delete(child)
if (code !== 0) {
reject(new Error(`worker ${shardIndex} exited ${code}\n${stderr}`))
return
}
try {
resolve(JSON.parse(stdout))
} catch (error) {
reject(new Error(`worker ${shardIndex} returned invalid JSON\n${stdout}\n${stderr}\n${error}`))
}
})
})
}
async function runWorker({
bossCount,
bossDamagePercent,
bossFilter,
bossHpPercent,
classes,
gearLevel,
repeats,
requiredBosses,
shardIndex,
shardCount,
maxSeconds,
stages,
}) {
const server = await createServer({
appType: 'custom',
configFile: 'vite.config.ts',
logLevel: 'error',
server: { middlewareMode: true },
})
try {
const modules = await loadSimModules(server)
const bossIds = filteredBossIds(Object.keys(modules.IWT2_BOSS_METADATA), bossFilter)
const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses)
const healerStyles = filteredClasses(classes)
const orderedBossGroups = permutations(bossIds, bossCount)
.filter((group) => requiredBossIds.every((bossId) => group.includes(bossId)))
if (orderedBossGroups.length === 0) {
throw new Error(`No boss combinations generated. Boss count ${bossCount}; required bosses: ${requiredBossIds.join(', ') || 'none'}.`)
}
const tasks = []
for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex += 1) {
for (const stage of stages) {
for (const healerStyle of healerStyles) {
for (const group of orderedBossGroups) {
tasks.push({
bossDamagePercent,
bossHpPercent,
bossIds: group,
gearLevel,
healerStyle,
repeatIndex,
stage,
})
}
}
}
}
const results = []
for (let index = 0; index < tasks.length; index += 1) {
if (index % shardCount !== shardIndex) continue
results.push(simulateTrial(modules, tasks[index], maxSeconds))
}
console.log(JSON.stringify(results))
} finally {
await server.close()
}
}
async function loadSimModules(server) {
const sim = await server.ssrLoadModule('/src/modes/iwt2/sim/index.ts')
const bosses = await server.ssrLoadModule('/src/modes/iwt2/content/bosses.ts')
const healerAbilities = await server.ssrLoadModule('/src/modes/iwt2/content/healerAbilities.ts')
const gear = await server.ssrLoadModule('/src/modes/iwt2/content/pvpGearNormalization.ts')
const equipment = await server.ssrLoadModule('/src/modes/iwt2/sim/equipmentStats.ts')
const pressure = await server.ssrLoadModule('/src/modes/iwt2/sim/roguelikePressure.ts')
return {
...sim,
...bosses,
...healerAbilities,
...gear,
...equipment,
...pressure,
}
}
function simulateTrial(modules, task, maxSeconds) {
const gearProgress = modules.createIwt2PvpNormalizedGearProgress({
enabled: true,
gearLevel: task.gearLevel,
})
const bossHealthScale = (1 + Math.max(0, task.stage - 1) * BOSS_HEALTH_PER_STAGE)
* PVP_BOSS_HEALTH_MULTIPLIER
* (task.bossHpPercent / 100)
const damageScale = modules.roguelikeIncomingDamageScale(task.stage, 'dungeon') * (task.bossDamagePercent / 100)
let state = modules.createInitialIwt2ArenaState(
task.bossIds[0],
task.bossIds,
bossHealthScale,
damageScale,
modules.createRoguelikePressureState(task.stage, 'dungeon'),
ARENA_BOUNDS,
)
state = modules.applyIwt2PveGearStats(state, gearProgress)
const abilities = modules.applyIwt2PveGearToHealerAbilities(
modules.abilitiesForHealer(task.healerStyle),
gearProgress,
)
const cooldowns = {}
const eventCounts = {}
const mechanicDamage = {}
const sourceDamage = {}
const sourceDefeats = {}
let casts = 0
let maxActiveHazards = 0
let maxHostileAdds = 0
let minLivingHealthRatio = 1
let minLivingCount = state.party.length
let outcome = 'timeout'
for (let step = 0; step < Math.ceil(maxSeconds / DEFAULT_DT); step += 1) {
const healResult = maybeCastHeal(modules, state, abilities, cooldowns)
state = healResult.state
casts += healResult.cast ? 1 : 0
tickCooldowns(cooldowns, DEFAULT_DT)
const previousState = state
const previousEventId = state.nextEventId
state = modules.tickIwt2Arena(state, scriptedMovement(state, task.repeatIndex), DEFAULT_DT)
const newEvents = state.events.filter((event) => event.id >= previousEventId)
for (const event of newEvents) {
eventCounts[event.type] = (eventCounts[event.type] ?? 0) + 1
if (isDamageEvent(event) && typeof event.value === 'number') {
sourceDamage[event.sourceId] = (sourceDamage[event.sourceId] ?? 0) + event.value
const mechanic = mechanicLabelForEvent(event, previousState)
mechanicDamage[mechanic] = (mechanicDamage[mechanic] ?? 0) + event.value
} else if (event.type === 'entityDefeated' && typeof event.targetId === 'string' && event.targetId !== 'yian-kut-ku') {
sourceDefeats[event.sourceId] = (sourceDefeats[event.sourceId] ?? 0) + 1
}
}
maxActiveHazards = Math.max(maxActiveHazards, state.hazards.filter((hazard) => hazard.remainingSeconds > hazard.fadeSeconds).length)
maxHostileAdds = Math.max(maxHostileAdds, state.hostileAdds.filter((add) => add.health > 0).length)
const living = state.party.filter((member) => member.health > 0)
minLivingCount = Math.min(minLivingCount, living.length)
for (const member of living) {
minLivingHealthRatio = Math.min(minLivingHealthRatio, member.health / member.maxHealth)
}
if (state.bosses.every((boss) => boss.health <= 0)) {
outcome = 'victory'
break
}
if (state.party.every((member) => member.health <= 0)) {
outcome = 'defeat'
break
}
}
const remainingBossHealth = state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
const maxBossHealth = state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
const partyHealth = state.party.reduce((total, member) => total + Math.max(0, member.health), 0)
const partyMaxHealth = state.party.reduce((total, member) => total + member.maxHealth, 0)
return {
bossIds: task.bossIds,
comboKey: comboKey(task.bossIds),
deaths: state.party.filter((member) => member.health <= 0).length,
eventCounts,
gearLevel: task.gearLevel,
healerStyle: task.healerStyle,
mechanicDamage,
maxActiveHazards,
maxHostileAdds,
minLivingCount,
minLivingHealthRatio: round(minLivingHealthRatio),
outcome,
partyHealthRatio: round(partyHealth / partyMaxHealth),
remainingBossHealthRatio: round(remainingBossHealth / maxBossHealth),
repeatIndex: task.repeatIndex,
sourceDamage,
sourceDefeats,
seconds: round(state.time),
stage: task.stage,
casts,
}
}
function maybeCastHeal(modules, state, abilities, cooldowns) {
const healer = state.party.find((member) => member.id === 'player-healer')
if (!healer || healer.health <= 0) return { cast: false, state }
const living = state.party.filter((member) => member.health > 0)
if (living.length === 0) return { cast: false, state }
const lowest = [...living].sort((a, b) => healthRatio(a) - healthRatio(b))[0]
const disabled = [...living]
.filter((member) => member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0)
.sort((a, b) => healthRatio(a) - healthRatio(b))[0]
const woundedCount = living.filter((member) => healthRatio(member) < 0.82).length
const candidates = [
woundedCount >= 3 ? ['group', lowest.id] : null,
disabled && healthRatio(disabled) < 0.95 ? ['cleanse', disabled.id] : null,
lowest && healthRatio(lowest) < 0.42 ? ['direct', lowest.id] : null,
lowest && healthRatio(lowest) < 0.78 && lowest.shield <= 0 ? ['shield', lowest.id] : null,
lowest && healthRatio(lowest) < 0.88 && !hasHot(lowest) ? ['hot', lowest.id] : null,
lowest && healthRatio(lowest) < 0.72 ? ['direct', lowest.id] : null,
].filter(Boolean)
for (const [kind, targetId] of candidates) {
const ability = abilities.find((candidate) => candidate.kind === kind && (cooldowns[candidate.id] ?? 0) <= 0)
if (!ability) continue
const result = modules.castIwt2HealerAbility(state, ability, targetId)
if (!result.cast) continue
cooldowns[ability.id] = ability.cooldownSeconds
return result
}
return { cast: false, state }
}
function scriptedMovement(state, repeatIndex = 0) {
const healer = state.party.find((member) => member.id === 'player-healer')
if (!healer || healer.health <= 0) return { moveX: 0, moveY: 0 }
const livingBosses = state.bosses.filter((boss) => boss.health > 0)
const nearestBoss = livingBosses
.map((boss) => ({ boss, distance: Math.hypot(boss.position.x - healer.position.x, boss.position.y - healer.position.y) }))
.sort((a, b) => a.distance - b.distance)[0]?.boss
const away = nearestBoss
? normalize({ x: healer.position.x - nearestBoss.position.x, y: healer.position.y - nearestBoss.position.y })
: { x: 0, y: 0 }
const orbitSeconds = Math.max(1, state.time) + repeatIndex * 1.731
const orbit = normalize({
x: Math.cos(orbitSeconds * 0.75),
y: Math.sin(orbitSeconds * 0.75) * 0.55,
})
const vector = normalize({
x: away.x * 0.6 + orbit.x * 0.4,
y: away.y * 0.6 + orbit.y * 0.4,
})
return { moveX: vector.x, moveY: vector.y }
}
function summarizeResults(results) {
const byCombo = new Map()
const byStageCombo = new Map()
const byBoss = new Map()
for (const result of results) {
pushGroup(byCombo, result.comboKey, result)
pushGroup(byStageCombo, `${result.stage}:${result.comboKey}`, result)
for (const bossId of new Set(result.bossIds)) {
pushGroup(byBoss, bossId, result)
}
}
return {
hardestCombos: [...byCombo.entries()]
.map(([key, items]) => summarizeGroup(key, items))
.sort(compareDifficulty),
hardestStageCombos: [...byStageCombo.entries()]
.map(([key, items]) => summarizeGroup(key, items))
.sort(compareDifficulty),
topMechanics: topSources(results, 'mechanicDamage'),
weakestSingleBosses: [...byBoss.entries()]
.map(([key, items]) => summarizeGroup(key, items))
.sort(compareDifficulty),
}
}
function summarizeGroup(key, items) {
const failures = items.filter((item) => item.outcome !== 'victory').length
const wins = items.length - failures
return {
key,
trials: items.length,
failures,
winRate: round(wins / items.length),
avgDeaths: round(average(items.map((item) => item.deaths))),
avgSeconds: round(average(items.map((item) => item.seconds))),
avgRemainingBossHealth: round(average(items.map((item) => item.remainingBossHealthRatio))),
worstRemainingBossHealth: round(Math.max(...items.map((item) => item.remainingBossHealthRatio))),
worstMinLivingCount: Math.min(...items.map((item) => item.minLivingCount)),
outcomes: countBy(items.map((item) => item.outcome)),
topDamageSources: topSources(items, 'sourceDamage'),
topDefeatSources: topSources(items, 'sourceDefeats'),
topMechanics: topSources(items, 'mechanicDamage'),
maxActiveHazards: Math.max(...items.map((item) => item.maxActiveHazards)),
maxHostileAdds: Math.max(...items.map((item) => item.maxHostileAdds)),
}
}
function compareDifficulty(a, b) {
return (b.failures - a.failures)
|| (a.winRate - b.winRate)
|| (b.avgDeaths - a.avgDeaths)
|| (b.avgRemainingBossHealth - a.avgRemainingBossHealth)
|| (b.avgSeconds - a.avgSeconds)
}
function pushGroup(map, key, item) {
const items = map.get(key)
if (items) items.push(item)
else map.set(key, [item])
}
function isDamageEvent(event) {
return event.type === 'partyDamaged'
|| event.type === 'bossChargeHit'
|| event.type === 'bossProjectileHit'
|| event.type === 'groundHazardTick'
}
function mechanicLabelForEvent(event, state) {
const sourceId = String(event.sourceId)
if (sourceId === 'roguelike-pressure') return 'Roguelike pressure'
if (sourceId.startsWith('yian-bird')) {
return event.type === 'groundHazardTick'
? 'yian-kut-ku: bird fire puddle'
: 'yian-kut-ku: bird add'
}
if (event.type === 'bossProjectileHit') return `${sourceId}: projectile`
if (event.type === 'groundHazardTick') return `${sourceId}: ground hazard`
if (event.type === 'bossChargeHit') return `${sourceId}: charge / dash`
const boss = state.bosses?.find((entry) => entry.id === sourceId)
if (!boss) return `${sourceId}: ${event.type}`
return `${sourceId}: ${phaseMechanicLabel(boss.attackPhase)}`
}
function phaseMechanicLabel(phase) {
if (phase.includes('Windup') || phase.includes('Recover')) {
return phase
.replace(/Windup$/, '')
.replace(/Recover$/, '')
.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
.trim()
}
if (phase.includes('Pouncing')) return 'pounce'
if (phase.includes('Charging')) return 'charge / dash'
if (phase.includes('Ricocheting')) return 'ricochet'
if (phase.includes('Burrowing')) return 'burrow'
if (phase === 'fireballRecover' || phase === 'fireballWindup') return 'fireball'
if (phase === 'idle') return 'melee / contact'
return phase.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`).trim()
}
function parseArgs(rawArgs) {
const parsed = new Map()
for (let index = 0; index < rawArgs.length; index += 1) {
const arg = rawArgs[index]
if (!arg.startsWith('--')) continue
const inlineValueIndex = arg.indexOf('=')
if (inlineValueIndex >= 0) {
parsed.set(arg.slice(0, inlineValueIndex), arg.slice(inlineValueIndex + 1))
continue
}
const next = rawArgs[index + 1]
if (next && !next.startsWith('--')) {
parsed.set(arg, next)
index += 1
} else {
parsed.set(arg, 'true')
}
}
return parsed
}
function printHelp() {
console.log(`IWT2 PvP roguelike boss simulator
Usage:
node scripts/iwt2-pvp-roguelike-boss-sim.mjs [options]
Options:
--classes all|dawnweaver,lifebinder,runesage
--gear-level 0|5|N
--boss-count 2|3|N
--bosses all|bulldrome,yian-kut-ku,...
--required-bosses none|yian-kut-ku,...
--repeats 1
--estimate-only
--boss-hp-percent 100
--boss-damage-percent 100
--stages 1,2
--seconds 180
--workers 8
--top 24
Examples:
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --classes dawnweaver --gear-level 0
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --classes all --gear-level 5 --boss-count 3
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --boss-hp-percent 125 --boss-damage-percent 110
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --bosses yian-kut-ku,rathian,rimebastion --boss-count 2
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --repeats 10
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --estimate-only
`)
}
function estimateRun({
bossCount,
bossFilter,
classes,
maxSeconds,
repeats,
requiredBosses,
stages,
workers,
}) {
const bossIds = filteredBossIds(BOSS_IDS, bossFilter)
const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses)
const healerStyles = filteredClasses(classes)
const orderedBossGroups = permutations(bossIds, bossCount)
.filter((group) => requiredBossIds.every((bossId) => group.includes(bossId)))
const trials = orderedBossGroups.length * healerStyles.length * stages.length * repeats
const maxTicks = trials * maxSeconds * (1 / DEFAULT_DT)
const estimatedWallSeconds = ESTIMATE_STARTUP_SECONDS + (maxTicks / (Math.max(1, workers) * DEFAULT_TICKS_PER_WORKER_SECOND))
return {
bossPoolSize: bossIds.length,
classCount: healerStyles.length,
orderedBossGroupCount: orderedBossGroups.length,
requiredBosses: requiredBossIds,
stageCount: stages.length,
repeats,
trials,
maxSimulatedSeconds: round(trials * maxSeconds),
maxTicks: Math.round(maxTicks),
estimatedWallSeconds: round(estimatedWallSeconds),
note: 'Rough wall-time estimate; victories or defeats can end trials early.',
}
}
function filteredBossIds(allBossIds, filter) {
if (filter.length === 0 || filter.includes('all')) return allBossIds
const known = new Set(allBossIds)
const selected = filter.filter((bossId) => known.has(bossId))
if (selected.length === 0) {
throw new Error(`No valid bosses selected. Known bosses: ${allBossIds.join(', ')}`)
}
return selected
}
function filteredClasses(classes) {
if (classes.length === 0 || classes.includes('all')) return HEALER_STYLES
const selected = classes.filter((healerStyle) => HEALER_STYLES.includes(healerStyle))
if (selected.length === 0) {
throw new Error(`No valid classes selected. Known classes: ${HEALER_STYLES.join(', ')}`)
}
return selected
}
function filteredRequiredBossIds(bossIds, requiredBosses) {
if (requiredBosses.length === 0 || requiredBosses.includes('none') || requiredBosses.includes('all')) return []
const bossSet = new Set(bossIds)
const selected = requiredBosses.filter((bossId) => bossSet.has(bossId))
if (selected.length === 0) {
throw new Error(`No required bosses are in the selected boss pool. Required: ${requiredBosses.join(', ')}. Pool: ${bossIds.join(', ')}`)
}
return [...new Set(selected)]
}
function permutations(items, count) {
const safeCount = Math.max(1, Math.min(items.length, Math.floor(count)))
const results = []
const walk = (current, remaining) => {
if (current.length === safeCount) {
results.push(current)
return
}
for (let index = 0; index < remaining.length; index += 1) {
const next = remaining[index]
walk([...current, next], remaining.filter((_, remainingIndex) => remainingIndex !== index))
}
}
walk([], items)
return results
}
function parseList(value) {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function parseStages(value) {
return value.split(',').map((stage) => Number.parseInt(stage, 10)).filter(Number.isFinite)
}
function parsePositiveInt(value, fallback) {
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
function parseNonNegativeInt(value, fallback) {
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
}
function parsePositiveNumber(value, fallback) {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
function comboKey(bossIds) {
return [...bossIds].sort().join(' + ')
}
function hasHot(member) {
return member.hotEffects.some((effect) => effect.remainingSeconds > 1.5)
}
function healthRatio(member) {
return member.health / member.maxHealth
}
function tickCooldowns(cooldowns, dt) {
for (const [id, remaining] of Object.entries(cooldowns)) {
const next = Math.max(0, remaining - dt)
if (next > 0) cooldowns[id] = next
else delete cooldowns[id]
}
}
function countBy(items) {
return items.reduce((counts, item) => {
counts[item] = (counts[item] ?? 0) + 1
return counts
}, {})
}
function topSources(items, key) {
const totals = {}
for (const item of items) {
for (const [sourceId, value] of Object.entries(item[key] ?? {})) {
totals[sourceId] = (totals[sourceId] ?? 0) + value
}
}
return Object.entries(totals)
.sort((first, second) => second[1] - first[1])
.slice(0, 5)
.map(([sourceId, value]) => ({ sourceId, value: round(value) }))
}
function average(values) {
return values.reduce((total, value) => total + value, 0) / Math.max(1, values.length)
}
function normalize(vector) {
const magnitude = Math.hypot(vector.x, vector.y)
if (magnitude <= 0.0001) return { x: 0, y: 0 }
return { x: vector.x / magnitude, y: vector.y / magnitude }
}
function round(value) {
return Math.round(value * 1000) / 1000
}
+662
View File
@@ -0,0 +1,662 @@
import http from 'node:http'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SCRIPT_DIR, '..')
const SIM_SCRIPT = resolve(SCRIPT_DIR, 'iwt2-pvp-roguelike-boss-sim.mjs')
const PORT = Number.parseInt(process.env.IWT2_SIM_GUI_PORT ?? '8787', 10)
const HEALER_CLASSES = ['dawnweaver', 'lifebinder', 'runesage']
const BOSS_IDS = [
'bulldrome',
'yian-kut-ku',
'great-jaggi',
'khezu',
'rathian',
'barroth',
'tobi-kadachi',
'rimebastion',
'ember-mantis-duelist',
'cinderback-ricochet',
'obsidian-ram-golem',
'stormcoil-wyrm',
'venom-orchid-hydra',
'sandglass-scorpion',
'crystal-bat-matriarch',
'hollowcrown-revenant',
]
let activeRun = null
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${PORT}`}`)
if (request.method === 'GET' && url.pathname === '/') {
sendHtml(response)
return
}
if (request.method === 'GET' && url.pathname === '/config') {
sendJson(response, { bosses: BOSS_IDS, classes: HEALER_CLASSES })
return
}
if (request.method === 'POST' && url.pathname === '/run') {
const body = await readJson(request)
startRun(body, response)
return
}
if (request.method === 'POST' && url.pathname === '/stop') {
stopRun()
sendJson(response, { ok: true })
return
}
response.writeHead(404)
response.end('not found')
})
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use. Try: IWT2_SIM_GUI_PORT=${PORT + 1} node scripts/iwt2-pvp-sim-gui-server.mjs`)
process.exit(1)
}
throw error
})
server.listen(PORT, () => {
console.log(`IWT2 PvP sim GUI: http://localhost:${PORT}`)
})
function startRun(options, response) {
if (activeRun) {
response.writeHead(409, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'A simulation is already running.' }))
return
}
const command = [
process.execPath,
SIM_SCRIPT,
'--classes',
listOrAll(options.classes, HEALER_CLASSES),
'--gear-level',
scalar(options.gearLevel, '5'),
'--boss-count',
scalar(options.bossCount, '2'),
'--bosses',
listOrAll(options.bosses, BOSS_IDS),
'--boss-hp-percent',
scalar(options.bossHpPercent, '100'),
'--boss-damage-percent',
scalar(options.bossDamagePercent, '100'),
'--stages',
scalar(options.stages, '1,2'),
'--seconds',
scalar(options.seconds, '180'),
'--workers',
scalar(options.workers, '8'),
'--repeats',
scalar(options.repeats, '1'),
'--required-bosses',
listOrNone(options.requiredBosses, BOSS_IDS),
'--top',
scalar(options.top, '24'),
]
response.writeHead(200, {
'cache-control': 'no-cache',
'connection': 'keep-alive',
'content-type': 'text/event-stream',
})
writeEvent(response, 'status', { command: command.join(' '), status: 'started' })
const child = spawn(command[0], command.slice(1), {
cwd: ROOT,
env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' },
stdio: ['ignore', 'pipe', 'pipe'],
})
activeRun = child
let output = ''
child.stdout.on('data', (chunk) => {
const text = chunk.toString()
output += text
writeEvent(response, 'chunk', { text })
})
child.stderr.on('data', (chunk) => {
const text = chunk.toString()
output += text
writeEvent(response, 'chunk', { text })
})
child.on('close', (code) => {
activeRun = null
writeEvent(response, 'done', {
code,
json: extractJson(output),
})
response.end()
})
child.on('error', (error) => {
activeRun = null
writeEvent(response, 'error', { error: String(error) })
response.end()
})
}
function stopRun() {
if (!activeRun) return
activeRun.kill('SIGTERM')
}
function sendHtml(response) {
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
response.end(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IWT2 PvP Sim GUI</title>
<style>
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111419; color: #eef2f6; }
body { margin: 0; min-height: 100vh; background: #111419; }
main { display: grid; grid-template-columns: 340px minmax(0, 1fr); min-height: 100vh; }
aside { border-right: 1px solid #2a3039; padding: 18px; background: #171b22; overflow: auto; }
section { padding: 18px; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
h1 { font-size: 18px; margin: 0 0 16px; }
h2 { font-size: 13px; margin: 18px 0 8px; color: #b7c2d0; text-transform: uppercase; letter-spacing: .05em; }
label { display: flex; align-items: center; gap: 8px; margin: 7px 0; font-size: 13px; }
input[type="text"], input[type="number"] { width: 100%; box-sizing: border-box; background: #0f1217; color: #eef2f6; border: 1px solid #333b47; border-radius: 6px; padding: 8px 9px; }
input[type="checkbox"] { accent-color: #61dafb; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.bosses { max-height: 300px; overflow: auto; border: 1px solid #2a3039; border-radius: 6px; padding: 8px; background: #111419; }
.buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 16px; }
.estimate { display: grid; gap: 6px; background: #111419; border: 1px solid #2a3039; border-radius: 6px; color: #d9e3ee; font-size: 13px; margin-top: 16px; padding: 10px; }
.estimate strong { font-size: 18px; }
.estimate span { color: #96a3b5; }
button { border: 1px solid #3a4656; color: #eef2f6; background: #202733; border-radius: 6px; padding: 9px 10px; cursor: pointer; }
button.primary { background: #245a7a; border-color: #327aa3; }
button:disabled { opacity: .55; cursor: not-allowed; }
pre { margin: 0; padding: 12px; overflow: auto; background: #0b0e12; border: 1px solid #2a3039; border-radius: 6px; min-height: 180px; white-space: pre; }
#dashboard { display: grid; gap: 12px; }
.empty { border: 1px dashed #3a4656; border-radius: 6px; color: #96a3b5; padding: 22px; text-align: center; }
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.metric { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; padding: 12px; }
.metric span { color: #96a3b5; display: block; font-size: 12px; margin-bottom: 6px; }
.metric strong { display: block; font-size: 24px; }
.panel { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
.panel h2 { margin: 0; padding: 12px; border-bottom: 1px solid #2a3039; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #242a33; padding: 9px 10px; text-align: left; vertical-align: top; }
th { background: #111419; color: #b7c2d0; font-weight: 600; position: sticky; top: 0; }
tr:last-child td { border-bottom: 0; }
.number { text-align: right; font-variant-numeric: tabular-nums; }
.pill { display: inline-block; border: 1px solid #3a4656; border-radius: 999px; color: #d9e3ee; background: #202733; padding: 2px 7px; margin: 0 4px 4px 0; font-size: 12px; }
.risk-high { color: #ff8d7a; }
.risk-mid { color: #ffd166; }
.risk-low { color: #7fe3a1; }
.bars { display: grid; gap: 9px; padding: 12px; }
.bar-row { display: grid; grid-template-columns: minmax(170px, 260px) 1fr 90px; gap: 10px; align-items: center; font-size: 13px; }
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d9e3ee; }
.bar-track { height: 12px; background: #0f1217; border: 1px solid #2a3039; border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #61dafb, #7fe3a1); border-radius: inherit; }
.bar-value { color: #b7c2d0; font-variant-numeric: tabular-nums; text-align: right; }
details { border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
summary { cursor: pointer; padding: 10px 12px; background: #171b22; color: #b7c2d0; }
#raw { min-height: 220px; border-radius: 0; border-left: 0; border-right: 0; border-bottom: 0; }
.status { color: #b7c2d0; font-size: 13px; }
@media (max-width: 1100px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 820px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #2a3039; } .cards { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<aside>
<h1>IWT2 PvP Sim</h1>
<h2>Scalars</h2>
<div class="grid">
<label>Gear <input id="gearLevel" type="number" value="5" min="0"></label>
<label>Boss count <input id="bossCount" type="number" value="2" min="1"></label>
<label>Boss HP % <input id="bossHpPercent" type="number" value="100" min="1"></label>
<label>Boss dmg % <input id="bossDamagePercent" type="number" value="100" min="1"></label>
<label>Stages <input id="stages" type="text" value="1,2"></label>
<label>Seconds <input id="seconds" type="number" value="180" min="1"></label>
<label>Workers <input id="workers" type="number" value="8" min="1"></label>
<label>Repeats <input id="repeats" type="number" value="1" min="1"></label>
<label>Top <input id="top" type="number" value="24" min="1"></label>
</div>
<h2>Classes</h2>
<label><input id="allClasses" type="checkbox" checked> All classes</label>
<div id="classes"></div>
<h2>Bosses</h2>
<label><input id="allBosses" type="checkbox" checked> All bosses</label>
<div id="bosses" class="bosses"></div>
<h2>Required Bosses</h2>
<div id="requiredBosses" class="bosses"></div>
<div id="estimate" class="estimate"></div>
<div class="buttons">
<button id="run" class="primary">Run</button>
<button id="stop" disabled>Stop</button>
<button id="copy">Copy Command</button>
<button id="save">Save JSON</button>
</div>
</aside>
<section>
<div id="status" class="status">Idle</div>
<div id="dashboard"><div class="empty">Run a simulation to see dashboard results.</div></div>
<details open>
<summary>Raw output</summary>
<pre id="raw"></pre>
</details>
</section>
</main>
<script>
const state = { lastJson: null, running: false, config: null };
const ids = ["gearLevel","bossCount","bossHpPercent","bossDamagePercent","stages","seconds","workers","repeats","top"];
const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
const status = document.getElementById("status");
const raw = document.getElementById("raw");
const dashboard = document.getElementById("dashboard");
const estimateBox = document.getElementById("estimate");
const run = document.getElementById("run");
const stop = document.getElementById("stop");
fetch("/config").then(r => r.json()).then(config => {
state.config = config;
renderChecks("classes", config.classes, true);
renderChecks("bosses", config.bosses, true);
renderChecks("requiredBosses", config.bosses, false);
wireAll("allClasses", "classes");
wireAll("allBosses", "bosses");
wireEstimateUpdates();
updateEstimate();
});
function renderChecks(containerId, values, checked) {
const container = document.getElementById(containerId);
container.innerHTML = values.map(value => '<label><input type="checkbox" value="' + value + '"' + (checked ? ' checked' : '') + '> ' + value + '</label>').join("");
}
function wireAll(masterId, containerId) {
const master = document.getElementById(masterId);
const container = document.getElementById(containerId);
master.addEventListener("change", () => {
container.querySelectorAll("input").forEach(input => input.checked = master.checked);
updateEstimate();
});
container.addEventListener("change", () => {
master.checked = [...container.querySelectorAll("input")].every(input => input.checked);
updateEstimate();
});
}
function selected(containerId, masterId) {
if (document.getElementById(masterId).checked) return ["all"];
const values = [...document.getElementById(containerId).querySelectorAll("input:checked")].map(input => input.value);
return values.length ? values : ["all"];
}
function selectedRequiredBosses() {
const values = [...document.getElementById("requiredBosses").querySelectorAll("input:checked")].map(input => input.value);
return values.length ? values : ["none"];
}
function payload() {
const requiredBosses = selectedRequiredBosses();
let bosses = selected("bosses", "allBosses");
if (!bosses.includes("all") && !requiredBosses.includes("none")) {
bosses = [...new Set([...bosses, ...requiredBosses])];
}
return {
bossCount: el.bossCount.value,
bossDamagePercent: el.bossDamagePercent.value,
bossHpPercent: el.bossHpPercent.value,
bosses,
classes: selected("classes", "allClasses"),
gearLevel: el.gearLevel.value,
repeats: el.repeats.value,
requiredBosses,
seconds: el.seconds.value,
stages: el.stages.value,
top: el.top.value,
workers: el.workers.value,
};
}
function commandFor(options) {
return ["node", "scripts/iwt2-pvp-roguelike-boss-sim.mjs",
"--classes", options.classes.join(","),
"--gear-level", options.gearLevel,
"--boss-count", options.bossCount,
"--bosses", options.bosses.join(","),
"--boss-hp-percent", options.bossHpPercent,
"--boss-damage-percent", options.bossDamagePercent,
"--stages", options.stages,
"--seconds", options.seconds,
"--workers", options.workers,
"--repeats", options.repeats,
"--required-bosses", options.requiredBosses.join(","),
"--top", options.top,
].join(" ");
}
function wireEstimateUpdates() {
document.querySelector("aside").addEventListener("input", updateEstimate);
document.querySelector("aside").addEventListener("change", updateEstimate);
}
function updateEstimate() {
if (!state.config) return;
const estimate = estimateFor(payload());
estimateBox.innerHTML = '<strong>' + number(estimate.trials) + ' tests</strong>' +
'<span>' + number(estimate.orderedBossGroupCount) + ' ordered boss groups x ' + number(estimate.classCount) + ' classes x ' + number(estimate.stageCount) + ' stages x ' + number(estimate.repeats) + ' repeats</span>' +
'<span>Max sim time: ' + duration(estimate.maxSimulatedSeconds) + ' / rough wall time: ' + duration(estimate.estimatedWallSeconds) + '</span>';
}
function estimateFor(options) {
const bossPool = options.bosses.includes("all") ? state.config.bosses : options.bosses;
const requiredBosses = options.requiredBosses.includes("none")
? []
: options.requiredBosses.filter(bossId => bossPool.includes(bossId));
const bossCount = intValue(options.bossCount, 2);
const orderedBossGroupCount = countOrderedBossGroups(bossPool.length, bossCount, requiredBosses.length);
const classCount = options.classes.includes("all") ? state.config.classes.length : Math.max(1, options.classes.length);
const stageCount = Math.max(1, parseStages(options.stages).length);
const repeats = intValue(options.repeats, 1);
const seconds = numberValue(options.seconds, 180);
const workers = intValue(options.workers, 8);
const trials = orderedBossGroupCount * classCount * stageCount * repeats;
const maxSimulatedSeconds = trials * seconds;
const maxTicks = maxSimulatedSeconds * 30;
const estimatedWallSeconds = 0.8 + (maxTicks / (Math.max(1, workers) * 45000));
return { classCount, estimatedWallSeconds, maxSimulatedSeconds, orderedBossGroupCount, repeats, stageCount, trials };
}
function countOrderedBossGroups(poolSize, bossCount, requiredCount) {
const safeBossCount = Math.max(1, Math.min(poolSize, Math.floor(bossCount)));
if (requiredCount > safeBossCount || requiredCount > poolSize) return 0;
return combination(poolSize - requiredCount, safeBossCount - requiredCount) * permutation(safeBossCount, safeBossCount);
}
function combination(n, k) {
if (k < 0 || k > n) return 0;
const safeK = Math.min(k, n - k);
let result = 1;
for (let index = 1; index <= safeK; index += 1) {
result = (result * (n - safeK + index)) / index;
}
return Math.round(result);
}
function permutation(n, k) {
if (k < 0 || k > n) return 0;
let result = 1;
for (let value = n - k + 1; value <= n; value += 1) result *= value;
return result;
}
function parseStages(value) {
return String(value).split(",").map(stage => Number.parseInt(stage.trim(), 10)).filter(Number.isFinite);
}
function intValue(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function numberValue(value, fallback) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function duration(seconds) {
const safeSeconds = Math.max(0, Number(seconds) || 0);
if (safeSeconds < 60) return fixed(safeSeconds) + 's';
const minutes = Math.floor(safeSeconds / 60);
const remaining = Math.round(safeSeconds % 60);
if (minutes < 60) return minutes + 'm ' + remaining + 's';
const hours = Math.floor(minutes / 60);
return hours + 'h ' + (minutes % 60) + 'm';
}
function number(value) {
return Number(value ?? 0).toLocaleString();
}
run.addEventListener("click", async () => {
const options = payload();
const estimate = estimateFor(options);
raw.textContent = "$ " + commandFor(options) + "\\n" +
"# estimated tests: " + number(estimate.trials) + ", rough wall time: " + duration(estimate.estimatedWallSeconds) + "\\n\\n";
dashboard.innerHTML = '<div class="empty">Simulation running...</div>';
state.lastJson = null;
state.running = true;
run.disabled = true;
stop.disabled = false;
status.textContent = "Running " + number(estimate.trials) + " tests, rough estimate " + duration(estimate.estimatedWallSeconds) + "...";
const response = await fetch("/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(options) });
if (!response.ok || !response.body) {
status.textContent = "Failed to start";
run.disabled = false;
stop.disabled = true;
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\\n\\n");
buffer = parts.pop() ?? "";
for (const part of parts) handleEvent(part);
}
});
function handleEvent(block) {
const lines = block.split("\\n");
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
const data = JSON.parse(lines.find(line => line.startsWith("data:"))?.slice(5) ?? "{}");
if (event === "chunk") {
raw.textContent += data.text;
raw.scrollTop = raw.scrollHeight;
} else if (event === "status") {
status.textContent = data.status;
} else if (event === "done") {
state.running = false;
run.disabled = false;
stop.disabled = true;
status.textContent = "Finished: exit " + data.code;
state.lastJson = data.json;
dashboard.innerHTML = data.json ? renderDashboard(JSON.parse(data.json)) : '<div class="empty">No JSON result found.</div>';
} else if (event === "error") {
status.textContent = data.error;
}
}
function renderDashboard(data) {
const combos = data.hardestCombos ?? [];
const stageCombos = data.hardestStageCombos ?? [];
const singleBosses = data.weakestSingleBosses ?? [];
const totalTrials = data.config?.orderedTrials ?? 0;
const totalFailures = combos.reduce((total, combo) => total + (combo.failures ?? 0), 0);
const worst = combos[0];
return [
'<div class="cards">',
metric('Trials', totalTrials),
metric('Elapsed', duration(data.config?.elapsedSeconds ?? 0)),
metric('Worst fail rate', worst ? percent(failRate(worst)) : 'n/a'),
metric('Gear / repeats', 'Gear +' + escapeHtml(data.config?.gearLevel ?? '?') + ' / ' + escapeHtml(data.config?.repeats ?? '?') + 'x'),
'</div>',
totalFailures === 0 ? '<div class="empty risk-low">No failures found in returned hardest-combo rows. Review deaths and fight time for near-fails.</div>' : '',
panel('Top Damage By Boss Mechanic', mechanicBars(data.topMechanics ?? [])),
panel('Hardest Combos', comboTable(combos, true)),
panel('Hardest By Stage', comboTable(stageCombos, false)),
panel('Bosses Associated With Most Risk', comboTable(singleBosses, true)),
].join('');
}
function metric(label, value) {
return '<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + value + '</strong></div>';
}
function panel(title, content) {
return '<div class="panel"><h2>' + escapeHtml(title) + '</h2>' + content + '</div>';
}
function comboTable(rows, showDamage) {
if (!rows.length) return '<div class="empty">No rows.</div>';
return '<table><thead><tr>' +
'<th>Combo</th><th class="number">Fail</th><th class="number">Win</th><th class="number">Deaths</th><th class="number">Time</th><th class="number">Boss HP Left</th><th>Top Mechanic</th><th>Top Source</th>' +
'</tr></thead><tbody>' +
rows.map(row => '<tr>' +
'<td><strong>' + escapeHtml(row.key) + '</strong><br>' + outcomePills(row.outcomes) + '</td>' +
'<td class="number ' + riskClass(failRate(row)) + '">' + escapeHtml(row.failures ?? 0) + '/' + escapeHtml(row.trials ?? 0) + '</td>' +
'<td class="number">' + percent(row.winRate ?? 0) + '</td>' +
'<td class="number">' + fixed(row.avgDeaths) + '</td>' +
'<td class="number">' + fixed(row.avgSeconds) + 's</td>' +
'<td class="number">' + percent(row.avgRemainingBossHealth ?? 0) + '</td>' +
'<td>' + damagePills(row.topMechanics) + '</td>' +
'<td>' + (showDamage ? damagePills(row.topDamageSources) : damagePills(row.topDamageSources)) + '</td>' +
'</tr>').join('') +
'</tbody></table>';
}
function mechanicBars(sources) {
if (!sources.length) return '<div class="empty">No mechanic damage recorded.</div>';
const max = Math.max(...sources.map(source => Number(source.value) || 0), 1);
return '<div class="bars">' + sources.slice(0, 12).map(source => {
const value = Number(source.value) || 0;
const width = Math.max(2, (value / max) * 100);
return '<div class="bar-row">' +
'<div class="bar-label" title="' + escapeHtml(source.sourceId) + '">' + escapeHtml(source.sourceId) + '</div>' +
'<div class="bar-track"><div class="bar-fill" style="width:' + width + '%"></div></div>' +
'<div class="bar-value">' + fixed(value) + '</div>' +
'</div>';
}).join('') + '</div>';
}
function outcomePills(outcomes) {
if (!outcomes) return '';
return Object.entries(outcomes)
.map(([name, count]) => '<span class="pill">' + escapeHtml(name) + ': ' + escapeHtml(count) + '</span>')
.join('');
}
function damagePills(sources) {
if (!sources || sources.length === 0) return '<span class="pill">none</span>';
return sources.slice(0, 3)
.map(source => '<span class="pill">' + escapeHtml(source.sourceId) + ': ' + fixed(source.value) + '</span>')
.join('');
}
function failRate(row) {
return row.trials ? (row.failures ?? 0) / row.trials : 0;
}
function riskClass(rate) {
if (rate >= 0.5) return 'risk-high';
if (rate > 0) return 'risk-mid';
return 'risk-low';
}
function percent(value) {
return fixed(value * 100) + '%';
}
function fixed(value) {
const number = Number(value ?? 0);
return Number.isInteger(number) ? String(number) : number.toFixed(3).replace(/0+$/, '').replace(/\\.$/, '');
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
stop.addEventListener("click", () => {
fetch("/stop", { method: "POST" });
status.textContent = "Stopping...";
});
document.getElementById("copy").addEventListener("click", () => {
navigator.clipboard.writeText(commandFor(payload()));
status.textContent = "Command copied";
});
document.getElementById("save").addEventListener("click", () => {
if (!state.lastJson) return;
const blob = new Blob([state.lastJson], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "iwt2-pvp-roguelike-sim.json";
link.click();
URL.revokeObjectURL(link.href);
});
</script>
</body>
</html>`)
}
function readJson(request) {
return new Promise((resolveRequest, rejectRequest) => {
let body = ''
request.on('data', (chunk) => {
body += chunk.toString()
})
request.on('end', () => {
try {
resolveRequest(body ? JSON.parse(body) : {})
} catch (error) {
rejectRequest(error)
}
})
request.on('error', rejectRequest)
})
}
function sendJson(response, value) {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify(value))
}
function writeEvent(response, event, data) {
response.write(`event: ${event}\n`)
response.write(`data: ${JSON.stringify(data)}\n\n`)
}
function listOrAll(value, allowed) {
if (!Array.isArray(value) || value.length === 0 || value.includes('all')) return 'all'
const selected = value.filter((item) => allowed.includes(item))
return selected.length ? selected.join(',') : 'all'
}
function listOrNone(value, allowed) {
if (!Array.isArray(value) || value.length === 0 || value.includes('none')) return 'none'
const selected = value.filter((item) => allowed.includes(item))
return selected.length ? selected.join(',') : 'none'
}
function scalar(value, fallback) {
return value === undefined || value === null || value === '' ? fallback : String(value)
}
function extractJson(output) {
const start = output.indexOf('{')
const end = output.lastIndexOf('}')
if (start < 0 || end < start) return ''
const candidate = output.slice(start, end + 1)
try {
return JSON.stringify(JSON.parse(candidate), null, 2)
} catch {
return ''
}
}
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import queue
import subprocess
import threading
from pathlib import Path
try:
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
except ModuleNotFoundError:
print(
"This Python install does not include Tkinter.\n"
"Use the browser GUI instead:\n"
" node scripts/iwt2-pvp-sim-gui-server.mjs\n"
"Then open http://localhost:8787\n"
)
raise SystemExit(1)
ROOT = Path(__file__).resolve().parents[1]
SIM_SCRIPT = ROOT / "scripts" / "iwt2-pvp-roguelike-boss-sim.mjs"
HEALER_CLASSES = ["dawnweaver", "lifebinder", "runesage"]
BOSS_IDS = [
"bulldrome",
"yian-kut-ku",
"great-jaggi",
"khezu",
"rathian",
"barroth",
"tobi-kadachi",
"rimebastion",
"ember-mantis-duelist",
"cinderback-ricochet",
"obsidian-ram-golem",
"stormcoil-wyrm",
"venom-orchid-hydra",
"sandglass-scorpion",
"crystal-bat-matriarch",
"hollowcrown-revenant",
]
class SimGui(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("IWT2 PvP Roguelike Sim")
self.geometry("1120x780")
self.minsize(920, 640)
self.output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue()
self.process: subprocess.Popen[str] | None = None
self.last_json = ""
self.class_vars = {name: tk.BooleanVar(value=True) for name in HEALER_CLASSES}
self.boss_vars = {boss_id: tk.BooleanVar(value=True) for boss_id in BOSS_IDS}
self.all_classes_var = tk.BooleanVar(value=True)
self.all_bosses_var = tk.BooleanVar(value=True)
self.gear_level_var = tk.StringVar(value="5")
self.boss_count_var = tk.StringVar(value="2")
self.hp_percent_var = tk.StringVar(value="100")
self.damage_percent_var = tk.StringVar(value="100")
self.stages_var = tk.StringVar(value="1,2")
self.seconds_var = tk.StringVar(value="180")
self.workers_var = tk.StringVar(value="8")
self.top_var = tk.StringVar(value="24")
self.status_var = tk.StringVar(value="Idle")
self._build_ui()
self.after(100, self._drain_output_queue)
def _build_ui(self) -> None:
self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1)
controls = ttk.Frame(self, padding=12)
controls.grid(row=0, column=0, sticky="nsew")
output_frame = ttk.Frame(self, padding=(0, 12, 12, 12))
output_frame.grid(row=0, column=1, sticky="nsew")
output_frame.columnconfigure(0, weight=1)
output_frame.rowconfigure(1, weight=1)
self._build_controls(controls)
output_header = ttk.Frame(output_frame)
output_header.grid(row=0, column=0, sticky="ew", pady=(0, 8))
output_header.columnconfigure(0, weight=1)
ttk.Label(output_header, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
ttk.Button(output_header, text="Save JSON", command=self._save_json).grid(row=0, column=1, padx=(8, 0))
ttk.Button(output_header, text="Clear", command=self._clear_output).grid(row=0, column=2, padx=(8, 0))
self.output = tk.Text(output_frame, wrap="none", font=("Menlo", 11))
self.output.grid(row=1, column=0, sticky="nsew")
yscroll = ttk.Scrollbar(output_frame, orient="vertical", command=self.output.yview)
yscroll.grid(row=1, column=1, sticky="ns")
xscroll = ttk.Scrollbar(output_frame, orient="horizontal", command=self.output.xview)
xscroll.grid(row=2, column=0, sticky="ew")
self.output.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
def _build_controls(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
ttk.Label(parent, text="Simulation").grid(row=0, column=0, sticky="w")
scalar_grid = ttk.Frame(parent)
scalar_grid.grid(row=1, column=0, sticky="ew", pady=(8, 12))
for column in range(2):
scalar_grid.columnconfigure(column, weight=1)
fields = [
("Gear level", self.gear_level_var),
("Boss count", self.boss_count_var),
("Boss HP %", self.hp_percent_var),
("Boss damage %", self.damage_percent_var),
("Stages", self.stages_var),
("Seconds", self.seconds_var),
("Workers", self.workers_var),
("Top rows", self.top_var),
]
for index, (label, variable) in enumerate(fields):
row = index // 2
column = (index % 2) * 2
ttk.Label(scalar_grid, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=3)
ttk.Entry(scalar_grid, textvariable=variable, width=10).grid(row=row, column=column + 1, sticky="ew", pady=3)
ttk.Checkbutton(
parent,
text="All classes",
variable=self.all_classes_var,
command=self._toggle_all_classes,
).grid(row=2, column=0, sticky="w")
class_frame = ttk.Frame(parent)
class_frame.grid(row=3, column=0, sticky="ew", pady=(4, 12))
for index, class_id in enumerate(HEALER_CLASSES):
ttk.Checkbutton(
class_frame,
text=class_id,
variable=self.class_vars[class_id],
command=self._sync_all_classes,
).grid(row=0, column=index, sticky="w", padx=(0, 10))
ttk.Checkbutton(
parent,
text="All bosses",
variable=self.all_bosses_var,
command=self._toggle_all_bosses,
).grid(row=4, column=0, sticky="w")
boss_canvas = tk.Canvas(parent, height=300, highlightthickness=0)
boss_scroll = ttk.Scrollbar(parent, orient="vertical", command=boss_canvas.yview)
boss_frame = ttk.Frame(boss_canvas)
boss_frame.bind(
"<Configure>",
lambda _event: boss_canvas.configure(scrollregion=boss_canvas.bbox("all")),
)
boss_canvas.create_window((0, 0), window=boss_frame, anchor="nw")
boss_canvas.configure(yscrollcommand=boss_scroll.set)
boss_canvas.grid(row=5, column=0, sticky="nsew", pady=(4, 12))
boss_scroll.grid(row=5, column=1, sticky="ns", pady=(4, 12))
for index, boss_id in enumerate(BOSS_IDS):
ttk.Checkbutton(
boss_frame,
text=boss_id,
variable=self.boss_vars[boss_id],
command=self._sync_all_bosses,
).grid(row=index, column=0, sticky="w", pady=2)
button_row = ttk.Frame(parent)
button_row.grid(row=6, column=0, sticky="ew")
button_row.columnconfigure(0, weight=1)
self.run_button = ttk.Button(button_row, text="Run Simulation", command=self._start_sim)
self.run_button.grid(row=0, column=0, sticky="ew")
self.stop_button = ttk.Button(button_row, text="Stop", command=self._stop_sim, state="disabled")
self.stop_button.grid(row=0, column=1, padx=(8, 0))
ttk.Button(parent, text="Copy Command", command=self._copy_command).grid(row=7, column=0, sticky="ew", pady=(8, 0))
def _toggle_all_classes(self) -> None:
value = self.all_classes_var.get()
for variable in self.class_vars.values():
variable.set(value)
def _sync_all_classes(self) -> None:
self.all_classes_var.set(all(variable.get() for variable in self.class_vars.values()))
def _toggle_all_bosses(self) -> None:
value = self.all_bosses_var.get()
for variable in self.boss_vars.values():
variable.set(value)
def _sync_all_bosses(self) -> None:
self.all_bosses_var.set(all(variable.get() for variable in self.boss_vars.values()))
def _selected_classes(self) -> str:
if self.all_classes_var.get():
return "all"
selected = [name for name, variable in self.class_vars.items() if variable.get()]
return ",".join(selected) if selected else "all"
def _selected_bosses(self) -> str:
if self.all_bosses_var.get():
return "all"
selected = [boss_id for boss_id, variable in self.boss_vars.items() if variable.get()]
return ",".join(selected) if selected else "all"
def _command(self) -> list[str]:
return [
"node",
str(SIM_SCRIPT),
"--classes",
self._selected_classes(),
"--gear-level",
self.gear_level_var.get().strip() or "5",
"--boss-count",
self.boss_count_var.get().strip() or "2",
"--bosses",
self._selected_bosses(),
"--boss-hp-percent",
self.hp_percent_var.get().strip() or "100",
"--boss-damage-percent",
self.damage_percent_var.get().strip() or "100",
"--stages",
self.stages_var.get().strip() or "1,2",
"--seconds",
self.seconds_var.get().strip() or "180",
"--workers",
self.workers_var.get().strip() or "8",
"--top",
self.top_var.get().strip() or "24",
]
def _start_sim(self) -> None:
if self.process is not None:
return
command = self._command()
self._clear_output()
self._append_output("$ " + " ".join(command) + "\n\n")
self.status_var.set("Running...")
self.run_button.configure(state="disabled")
self.stop_button.configure(state="normal")
thread = threading.Thread(target=self._run_subprocess, args=(command,), daemon=True)
thread.start()
def _run_subprocess(self, command: list[str]) -> None:
try:
self.process = subprocess.Popen(
command,
cwd=ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert self.process.stdout is not None
chunks: list[str] = []
for line in self.process.stdout:
chunks.append(line)
self.output_queue.put(("line", line))
exit_code = self.process.wait()
output = "".join(chunks)
self.output_queue.put(("done", json.dumps({"exitCode": exit_code, "output": output})))
except Exception as exc: # noqa: BLE001
self.output_queue.put(("error", str(exc)))
def _drain_output_queue(self) -> None:
try:
while True:
kind, payload = self.output_queue.get_nowait()
if kind == "line" and payload is not None:
self._append_output(payload)
elif kind == "done" and payload is not None:
result = json.loads(payload)
self.last_json = self._extract_json(result["output"])
self.status_var.set(f"Finished: exit {result['exitCode']}")
self.process = None
self.run_button.configure(state="normal")
self.stop_button.configure(state="disabled")
elif kind == "error" and payload is not None:
self.status_var.set("Failed")
self._append_output(f"\nERROR: {payload}\n")
self.process = None
self.run_button.configure(state="normal")
self.stop_button.configure(state="disabled")
except queue.Empty:
pass
self.after(100, self._drain_output_queue)
def _stop_sim(self) -> None:
if self.process is None:
return
self.process.terminate()
self.status_var.set("Stopping...")
def _clear_output(self) -> None:
self.output.delete("1.0", "end")
self.last_json = ""
def _append_output(self, text: str) -> None:
self.output.insert("end", text)
self.output.see("end")
def _copy_command(self) -> None:
command = " ".join(self._command())
self.clipboard_clear()
self.clipboard_append(command)
self.status_var.set("Command copied")
def _save_json(self) -> None:
output = self.last_json or self._extract_json(self.output.get("1.0", "end"))
if not output:
messagebox.showinfo("No JSON", "Run a simulation first.")
return
path = filedialog.asksaveasfilename(
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("All files", "*.*")],
initialfile="iwt2-pvp-roguelike-sim.json",
)
if not path:
return
Path(path).write_text(output, encoding="utf-8")
self.status_var.set(f"Saved {path}")
@staticmethod
def _extract_json(text: str) -> str:
start = text.find("{")
end = text.rfind("}")
if start < 0 or end < start:
return ""
candidate = text[start : end + 1]
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
return ""
return json.dumps(parsed, indent=2)
if __name__ == "__main__":
SimGui().mainloop()
+558 -15
View File
@@ -212,6 +212,8 @@
}
.iwt2-arena-layout {
display: grid;
grid-template-columns: 172px minmax(0, 1fr);
height: 100vh;
overflow: hidden;
padding: 0;
@@ -221,6 +223,7 @@
.iwt2-arena-stage {
background: #090c10;
border: 0;
grid-column: 2;
height: 100%;
min-height: 0;
overflow: hidden;
@@ -233,6 +236,14 @@
width: 100%;
}
.iwt2-arena-loading {
align-items: center;
color: var(--muted);
display: flex;
font-family: var(--pixel-font);
justify-content: center;
}
.iwt2-phaser-host canvas {
display: block;
}
@@ -317,6 +328,22 @@
top: 0;
}
.iwt2-bar em {
color: #fff7df;
font-size: 0.56rem;
font-style: normal;
font-weight: 800;
left: 0;
line-height: 1;
pointer-events: none;
position: absolute;
right: 0;
text-align: center;
text-shadow: 0 1px 0 #050608;
top: 50%;
transform: translateY(-50%);
}
.iwt2-bar.boss span {
background: #dc5162;
}
@@ -337,17 +364,18 @@
.iwt2-party-list {
background: rgba(15, 19, 26, 0.88);
border: 0;
left: 28px;
max-height: calc(100% - 112px);
bottom: 0;
left: 0;
max-height: none;
outline: 0;
padding: 6px;
position: absolute;
top: 28px;
position: fixed;
top: 0;
width: 172px;
z-index: 4;
display: grid;
gap: 8px;
align-content: start;
align-content: center;
overflow: auto;
}
@@ -1099,7 +1127,8 @@
.iwt2-gear-class,
.iwt2-gear-slot,
.iwt2-infusion-row,
.iwt2-gear-upgrade-button {
.iwt2-gear-upgrade-button,
.iwt2-gear-back {
background: #151922;
border: 2px solid #08090d;
color: var(--ink);
@@ -1177,7 +1206,8 @@
.iwt2-gear-class.game-selected,
.iwt2-gear-slot.game-selected,
.iwt2-infusion-row.game-selected,
.iwt2-gear-upgrade-button.game-selected {
.iwt2-gear-upgrade-button.game-selected,
.iwt2-gear-back.game-selected {
outline-color: #e5b95f;
}
@@ -1187,6 +1217,12 @@
text-align: center;
}
.iwt2-gear-back {
min-height: 36px;
padding: 8px 10px;
text-align: center;
}
.iwt2-gear-upgrade-button:disabled,
.iwt2-infusion-row:disabled {
color: var(--muted);
@@ -1401,6 +1437,10 @@
}
@media (max-width: 1000px) and (max-height: 620px) {
.iwt2-arena-layout {
grid-template-columns: 154px minmax(0, 1fr);
}
.game-shell.iwt2-shell {
padding: 6px 0;
width: min(100%, calc(100% - 20px));
@@ -1640,6 +1680,11 @@
padding: 7px 9px;
}
.iwt2-gear-back {
min-height: 30px;
padding: 5px 8px;
}
.iwt2-gear-detail {
gap: 6px;
padding: 7px;
@@ -1735,20 +1780,22 @@
}
.iwt2-party-list {
gap: 5px;
left: 10px;
max-height: calc(100% - 84px);
align-content: center;
gap: 7px;
bottom: 0;
left: 0;
max-height: none;
overflow: hidden;
padding: 4px;
top: 10px;
top: 0;
width: 154px;
}
.iwt2-party-row {
gap: 5px;
grid-template-columns: 22px minmax(0, 1fr);
height: 62px;
min-height: 62px;
height: 70px;
min-height: 70px;
padding: 4px;
}
@@ -1782,8 +1829,12 @@
}
.iwt2-party-row .iwt2-bar {
height: 7px;
margin-top: 2px;
height: 12px;
margin-top: 3px;
}
.iwt2-party-row .iwt2-bar.mana {
height: 8px;
}
.iwt2-party-effects {
@@ -1803,6 +1854,7 @@
.iwt2-ability-bar {
bottom: 8px;
display: none;
gap: 8px;
grid-template-columns: repeat(6, 36px);
max-width: 256px;
@@ -1829,6 +1881,497 @@
}
}
/* IWT2 startup save gateway */
.save-gateway-shell {
align-items: stretch;
height: 100dvh;
min-height: 0;
overflow: auto;
padding: 14px;
}
.save-gateway-panel {
background: rgba(17, 19, 25, 0.96);
border: 3px solid #08090c;
box-shadow: 8px 8px 0 #050609;
display: grid;
gap: 12px;
grid-template-rows: auto minmax(0, 1fr) auto auto;
margin: auto;
max-width: 1080px;
min-height: min(512px, calc(100dvh - 28px));
outline: 2px solid #4a4653;
padding: 16px;
width: 100%;
}
.save-gateway-heading {
align-items: end;
border-bottom: 2px solid #34343d;
display: flex;
gap: 22px;
justify-content: space-between;
padding: 0 2px 10px;
}
.save-gateway-heading h1 {
color: var(--ink);
font-family: var(--pixel-font);
font-size: clamp(24px, 4vw, 38px);
line-height: 1;
margin: 4px 0 0;
}
.save-gateway-heading > p {
color: var(--muted);
font-size: 14px;
line-height: 1.25;
margin: 0;
max-width: 440px;
text-align: right;
}
.save-option-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-height: 0;
}
.save-option-card {
background: #151921;
border: 2px solid #090a0d;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
outline: 2px solid #3a4250;
padding: 12px;
}
.local-save-card {
border-top-color: #5eaf7e;
}
.new-save-card {
border-top-color: #d8af58;
}
.online-save-card {
border-top-color: #5799db;
}
.save-option-heading {
align-items: center;
display: flex;
gap: 10px;
}
.save-option-heading > span {
align-items: center;
background: #202938;
border: 2px solid #090a0d;
color: var(--gold);
display: inline-flex;
flex: 0 0 42px;
font-family: var(--pixel-font);
height: 42px;
justify-content: center;
outline: 1px solid #4c5667;
}
.online-save-panel .save-option-heading > span {
color: #80bdf2;
}
.save-option-heading h2 {
color: var(--ink);
font-family: var(--pixel-font);
font-size: 15px;
line-height: 1.15;
margin: 3px 0 0;
}
.save-character-name {
color: var(--ink);
display: block;
font-size: 19px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.save-metadata {
display: grid;
gap: 7px;
margin: 0;
}
.save-metadata > div {
background: #101219;
border-left: 3px solid #4b5363;
display: grid;
gap: 3px;
padding: 7px 9px;
}
.save-metadata dt {
color: var(--muted);
font-family: var(--pixel-font);
font-size: 7px;
text-transform: uppercase;
}
.save-metadata dd {
color: var(--ink);
font-size: 14px;
line-height: 1.15;
margin: 0;
}
.save-empty-state {
align-items: center;
background: #101219;
border-left: 3px solid #4b5363;
color: var(--muted);
display: flex;
flex: 1;
font-size: 14px;
line-height: 1.25;
margin: 0;
min-height: 58px;
padding: 12px;
}
.save-continue-button,
.save-secondary-button {
border: 2px solid #090a0d;
cursor: pointer;
font-family: var(--pixel-font);
font-size: 8px;
line-height: 1.2;
min-height: 42px;
padding: 8px 10px;
width: 100%;
}
.slot-save-actions {
display: grid;
gap: 7px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: auto;
}
.slot-save-actions .save-continue-button {
margin-top: 0;
}
.character-create-form {
display: flex;
flex: 1;
flex-direction: column;
gap: 9px;
min-height: 0;
}
.character-create-form label,
.online-save-destination {
color: var(--muted);
display: grid;
font-family: var(--pixel-font);
font-size: 7px;
gap: 5px;
text-transform: uppercase;
}
.character-create-form input,
.online-save-destination select {
background: #0e1016;
border: 2px solid #090a0d;
color: var(--ink);
font: 16px var(--body-font);
min-height: 38px;
outline: 2px solid #3e3d47;
padding: 7px 9px;
width: 100%;
}
.character-create-form > small {
color: var(--muted);
font-size: 12px;
line-height: 1.2;
}
.online-save-panel {
align-items: center;
background: #131821;
border: 2px solid #090a0d;
border-top-color: #5799db;
display: grid;
gap: 12px;
grid-template-columns: 130px minmax(260px, 1fr) 160px minmax(170px, 210px);
min-width: 0;
outline: 2px solid #3a4250;
padding: 10px 12px;
}
.online-save-heading {
min-width: 0;
}
.online-save-summary {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: minmax(75px, 0.7fr) minmax(0, 1.8fr);
min-width: 0;
}
.online-save-summary .save-character-name {
font-size: 16px;
}
.online-save-summary .save-metadata {
gap: 5px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.online-save-summary .save-metadata > div {
padding: 5px 7px;
}
.online-save-summary .save-metadata dd {
font-size: 11px;
}
.online-save-summary .save-empty-state {
min-height: 48px;
}
.save-continue-button {
background: var(--gold);
color: #19150e;
margin-top: auto;
outline: 2px solid #816630;
}
.save-continue-button.danger {
background: var(--red-bright);
color: #fff6ee;
outline-color: #8d2b38;
}
.save-continue-button:disabled,
.save-secondary-button:disabled {
cursor: not-allowed;
filter: grayscale(0.8);
opacity: 0.45;
}
.save-secondary-button {
background: #242630;
color: var(--muted);
outline: 2px solid #4b4855;
}
.online-save-login {
display: grid;
gap: 9px;
grid-column: 2 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(180px, 0.75fr);
margin: 0;
}
.online-save-login label {
color: var(--muted);
display: grid;
font-family: var(--pixel-font);
font-size: 7px;
gap: 5px;
text-transform: uppercase;
}
.online-save-login input {
background: #0e1016;
border: 2px solid #090a0d;
color: var(--ink);
font: 16px var(--body-font);
min-height: 38px;
outline: 2px solid #3e3d47;
padding: 7px 9px;
width: 100%;
}
.online-save-actions {
display: grid;
gap: 7px;
margin-top: auto;
}
.online-save-actions .save-continue-button {
margin-top: 0;
}
.save-gateway-status {
color: #a9d7b8;
font-size: 13px;
line-height: 1.2;
margin: 0;
min-height: 16px;
padding: 0 2px;
}
.save-gateway-status.error {
color: #ff8190;
}
@media (max-width: 800px) {
.auth-shell.save-gateway-shell {
align-items: flex-start;
overflow: auto;
}
.save-gateway-panel {
min-height: auto;
}
.save-gateway-heading {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.save-gateway-heading > p {
text-align: left;
}
.save-option-grid {
grid-template-columns: 1fr;
}
.online-save-panel {
align-items: stretch;
grid-template-columns: 1fr;
}
.online-save-login {
grid-column: auto;
grid-template-columns: 1fr;
}
.online-save-summary {
grid-template-columns: 1fr;
}
}
@media (max-width: 1000px) and (max-height: 620px) {
.save-gateway-shell {
overflow: hidden;
padding: 10px;
}
.save-gateway-panel {
gap: 9px;
min-height: calc(100dvh - 20px);
padding: 11px;
}
.save-gateway-heading {
padding-bottom: 7px;
}
.save-gateway-heading h1 {
font-size: 25px;
}
.save-gateway-heading > p {
font-size: 12px;
}
.save-option-grid {
gap: 9px;
}
.save-option-card {
gap: 6px;
padding: 8px;
}
.save-option-heading > span {
flex-basis: 36px;
height: 36px;
}
.save-option-heading h2 {
font-size: 13px;
}
.save-character-name {
font-size: 16px;
}
.save-metadata {
gap: 5px;
}
.save-metadata > div {
padding: 5px 7px;
}
.save-metadata dd {
font-size: 12px;
}
.save-continue-button,
.save-secondary-button {
min-height: 34px;
padding: 6px 8px;
}
.slot-save-actions {
gap: 5px;
}
.character-create-form {
gap: 6px;
}
.character-create-form input,
.online-save-destination select {
font-size: 14px;
min-height: 32px;
padding: 5px 7px;
}
.character-create-form > small {
font-size: 10px;
}
.online-save-panel {
gap: 8px;
grid-template-columns: 115px minmax(230px, 1fr) 145px minmax(160px, 190px);
padding: 7px 9px;
}
.online-save-summary {
gap: 5px;
}
.online-save-login {
gap: 6px;
}
.online-save-login input {
font-size: 14px;
min-height: 32px;
padding: 5px 7px;
}
.save-gateway-status {
font-size: 11px;
}
}
.combat-touch-lock-status {
background: #101216;
border: 2px solid #d9b55a;
+31 -145
View File
@@ -1,159 +1,45 @@
import { useCallback, useEffect, useState } from 'react'
import { lazy, Suspense, useState } from 'react'
import { AuthScreen } from './components/AuthScreen'
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
import { useGameAction } from './input'
import {
loadAuthSession,
type AuthSession,
} from './profile'
import type { Iwt2LocalSaveSlot, Iwt2Save } from './modes/iwt2/save/iwt2Repository'
type GameVersion = 'iwt1' | 'iwt2'
const LazyIWantToHeal2App = lazy(() => import('./modes/iwt2/IWantToHeal2App').then((module) => ({
default: module.IWantToHeal2App,
})))
const GAME_OPTIONS: Array<{
version: GameVersion
title: string
label: string
description: string
glyph: string
}> = [
{
version: 'iwt1',
title: 'I Want To Heal 1',
label: 'Classic Healer Runs',
description: 'Original dungeon, raid, roguelike, PvP, gear, talents, and collection progression.',
glyph: 'I',
},
{
version: 'iwt2',
title: 'I Want To Heal 2',
label: '2D Boss Arena',
description: 'Move through boss arenas with a visible party, analog movement, projectiles, and separate progression.',
glyph: 'II',
},
]
type Iwt2Launch = {
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean
save: Iwt2Save
}
function App() {
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
const [authChecked, setAuthChecked] = useState(false)
const [serverMessage, setServerMessage] = useState('')
const [launch, setLaunch] = useState<Iwt2Launch | null>(null)
useEffect(() => {
let cancelled = false
loadAuthSession()
.then((session) => {
if (cancelled) return
setAuthSession(session.account && session.profile ? session : null)
})
.catch((reason: unknown) => {
if (cancelled) return
setServerMessage(
reason instanceof Error
? `${reason.message} Offline play is still available.`
: 'Unable to reach the server. Offline play is still available.',
)
})
.finally(() => {
if (!cancelled) setAuthChecked(true)
})
return () => {
cancelled = true
}
}, [])
const acceptSession = useCallback((session: AuthSession) => {
setAuthSession(session)
setSelectedVersion(null)
setServerMessage('')
}, [])
const clearAuthSession = useCallback(() => {
setAuthSession(null)
setSelectedVersion(null)
}, [])
useGameAction((action, device) => {
if (!authSession || selectedVersion || device !== 'controller') return
if (action === 'navigateLeft' || action === 'navigateUp') {
setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateRight' || action === 'navigateDown') {
setSelectedIndex((current) => Math.min(GAME_OPTIONS.length - 1, current + 1))
} else if (action === 'confirm') {
setSelectedVersion(GAME_OPTIONS[selectedIndex].version)
}
})
if (!authChecked) {
if (launch) {
return (
<main className="game-shell">
<section className="message-panel">
<p className="eyebrow">Opening Chronicle</p>
<Suspense fallback={<Iwt2LaunchFallback />}>
<LazyIWantToHeal2App
initialSave={launch.save}
localSlot={launch.localSlot}
onlineBackupsAvailable={launch.onlineBackupsAvailable}
onExitToSaveSelect={() => setLaunch(null)}
/>
</Suspense>
)
}
return <AuthScreen onContinue={setLaunch} />
}
function Iwt2LaunchFallback() {
return (
<main className="game-shell iwt2-shell">
<section className="message-panel" aria-live="polite">
<p className="eyebrow">I Want To Heal 2</p>
<h1>Loading...</h1>
</section>
</main>
)
}
if (!authSession) {
return (
<AuthScreen
onAuthenticated={acceptSession}
serverMessage={serverMessage}
/>
)
}
if (selectedVersion === 'iwt1') {
return (
<IWantToHeal1App
initialSession={authSession}
onAuthenticationCleared={clearAuthSession}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
}
if (selectedVersion === 'iwt2') {
return (
<IWantToHeal2App
onlineBackupsAvailable={authSession.account?.id !== -1}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
}
return (
<main className="game-shell game-version-shell">
<section className="game-version-screen" data-game-nav-active="true">
<div className="game-version-heading">
<p className="eyebrow">Select Game</p>
<h1>I Want To Heal</h1>
</div>
<div className="game-version-grid">
{GAME_OPTIONS.map((option, index) => (
<button
className={`game-version-card ${selectedIndex === index ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={option.version}
onClick={() => setSelectedVersion(option.version)}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
<span>{option.glyph}</span>
<div>
<strong>{option.title}</strong>
<small>{option.label}</small>
<p>{option.description}</p>
</div>
</button>
))}
</div>
</section>
</main>
)
}
export default App
+352 -126
View File
@@ -1,99 +1,381 @@
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
loadAuthSession,
loginAccount,
registerAccount,
type AuthSession,
logoutAccount,
type Account,
} from '../profile'
import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
import {
createOfflineCharacter,
hasOfflineCharacter,
resumeOfflineCharacter,
selectOnlineMode,
} from '../gameRepository'
createDefaultIwt2Save,
IWT2_LOCAL_SAVE_SLOTS,
loadIwt2OnlineSave,
loadIwt2SaveSlots,
replaceIwt2Save,
selectActiveIwt2SaveSlot,
type Iwt2LocalSaveSlot,
type Iwt2LocalSaveSlots,
type Iwt2Save,
} from '../modes/iwt2/save/iwt2Repository'
type Props = {
onAuthenticated: (session: AuthSession) => void
serverMessage?: string
type Iwt2Launch = {
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean
save: Iwt2Save
}
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
const [mode, setMode] = useState<'login' | 'register'>('login')
type Props = {
onContinue: (launch: Iwt2Launch) => void
}
const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/
function formatSaveTimestamp(updatedAt: number) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(updatedAt))
}
function SaveMetadata({ save }: { save: Iwt2Save }) {
return (
<dl className="save-metadata">
<div>
<dt>Last updated</dt>
<dd>
<time dateTime={new Date(save.updatedAt).toISOString()}>
{formatSaveTimestamp(save.updatedAt)}
</time>
</dd>
</div>
<div>
<dt>Character XP</dt>
<dd>{save.character.experience.toLocaleString()} XP</dd>
</div>
</dl>
)
}
function firstEmptySlot(saves: Iwt2LocalSaveSlots): Iwt2LocalSaveSlot {
return IWT2_LOCAL_SAVE_SLOTS.find((slot) => !saves[slot]) ?? 1
}
export function AuthScreen({ onContinue }: Props) {
const [localSaves] = useState(loadIwt2SaveSlots)
const [editingSlot, setEditingSlot] = useState<Iwt2LocalSaveSlot | null>(null)
const [characterName, setCharacterName] = useState('')
const [account, setAccount] = useState<Account | null>(null)
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [onlineDestinationSlot, setOnlineDestinationSlot] = useState<Iwt2LocalSaveSlot>(
() => firstEmptySlot(localSaves),
)
const [confirmOnlineDestination, setConfirmOnlineDestination] = useState<Iwt2LocalSaveSlot | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [characterName, setCharacterName] = useState('')
const [offlineName, setOfflineName] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const offlineCharacterExists = hasOfflineCharacter()
const [checkingSession, setCheckingSession] = useState(true)
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.')
const [messageIsError, setMessageIsError] = useState(false)
async function submit(event: React.FormEvent) {
const inspectOnlineSave = useCallback(async (cancelled: () => boolean = () => false) => {
setCheckingOnlineSave(true)
try {
const result = await loadIwt2OnlineSave()
if (cancelled()) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save ready. Choose its local destination slot.' : 'Account has no online IWT2 save yet.')
setMessageIsError(false)
} catch (reason) {
if (cancelled()) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
setMessageIsError(true)
} finally {
if (!cancelled()) setCheckingOnlineSave(false)
}
}, [])
useEffect(() => {
let cancelled = false
selectOnlineMode()
loadAuthSession()
.then((session) => {
if (cancelled || !session.account) return
setAccount(session.account)
return inspectOnlineSave(() => cancelled)
})
.catch((reason: unknown) => {
if (cancelled) return
setMessage(
reason instanceof Error
? `${reason.message} Local slots remain available.`
: 'Online service unavailable. Local slots remain available.',
)
setMessageIsError(true)
})
.finally(() => {
if (!cancelled) setCheckingSession(false)
})
return () => {
cancelled = true
}
}, [inspectOnlineSave])
function continueLocal(slot: Iwt2LocalSaveSlot) {
const save = localSaves[slot]
if (!save) return
selectActiveIwt2SaveSlot(slot)
selectOfflineMode()
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
}
function openCharacterEditor(slot: Iwt2LocalSaveSlot) {
setEditingSlot(slot)
setCharacterName('')
setMessage(localSaves[slot]
? `Creating a new character will replace Slot ${slot}.`
: `Enter a name for the new character in Slot ${slot}.`)
setMessageIsError(false)
}
function createCharacter(event: React.FormEvent, slot: Iwt2LocalSaveSlot) {
event.preventDefault()
setBusy(true)
setMessage('')
const name = characterName.trim()
if (!IWT2_CHARACTER_NAME_PATTERN.test(name)) {
setMessage('Character name must be 218 characters, start with a letter, and use letters, numbers, spaces, or hyphens.')
setMessageIsError(true)
return
}
const save = createDefaultIwt2Save(name)
replaceIwt2Save(save, slot)
selectActiveIwt2SaveSlot(slot)
selectOfflineMode()
onContinue({ localSlot: slot, save, onlineBackupsAvailable: false })
}
async function signIn(event: React.FormEvent) {
event.preventDefault()
setSubmitting(true)
setMessage('Signing in and checking online save...')
setMessageIsError(false)
try {
selectOnlineMode()
const session = mode === 'login'
? await loginAccount(username, password)
: await registerAccount(username, password, characterName)
onAuthenticated(session)
const session = await loginAccount(username, password)
if (!session.account) throw new Error('Account session was not returned.')
setAccount(session.account)
await inspectOnlineSave()
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
setAccount(null)
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to sign in.')
setMessageIsError(true)
} finally {
setBusy(false)
setSubmitting(false)
}
}
function beginOffline() {
setMessage('')
function continueOnline() {
if (!account || !onlineSave) return
const destinationSave = localSaves[onlineDestinationSlot]
if (destinationSave && confirmOnlineDestination !== onlineDestinationSlot) {
setConfirmOnlineDestination(onlineDestinationSlot)
setMessage(`Online save will replace ${destinationSave.character.name} in Slot ${onlineDestinationSlot}. Select again to confirm.`)
setMessageIsError(false)
return
}
replaceIwt2Save(onlineSave, onlineDestinationSlot)
selectActiveIwt2SaveSlot(onlineDestinationSlot)
selectOnlineMode()
onContinue({
localSlot: onlineDestinationSlot,
onlineBackupsAvailable: true,
save: onlineSave,
})
}
async function handleDifferentAccount() {
setSubmitting(true)
try {
onAuthenticated(createOfflineCharacter(offlineName))
await logoutAccount()
setAccount(null)
setOnlineSave(null)
setPassword('')
setMessage('Signed out. Enter account credentials to load another online save.')
setMessageIsError(false)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.')
setMessage(reason instanceof Error ? reason.message : 'Unable to sign out.')
setMessageIsError(true)
} finally {
setSubmitting(false)
}
}
function resumeOffline() {
const session = resumeOfflineCharacter()
if (session) onAuthenticated(session)
}
const onlineBusy = checkingSession || checkingOnlineSave || submitting
return (
<main className="auth-shell">
<section className="auth-panel">
<div className="auth-brand">
<p className="eyebrow">Healer RPG</p>
<h1>I want to Heal</h1>
<p>
Build your healer, master each dungeon, and compete for the most
efficient clears.
<main className="auth-shell save-gateway-shell">
<section className="save-gateway-panel">
<header className="save-gateway-heading">
<div>
<p className="eyebrow">I Want To Heal 2</p>
<h1>Choose Character</h1>
</div>
<p>Three local slots. Each character keeps separate progress, timestamp, and XP.</p>
</header>
<div className="save-option-grid">
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
const save = localSaves[slot]
const editing = editingSlot === slot
return (
<article className={`save-option-card local-save-card ${save ? '' : 'empty'}`} key={slot}>
<div className="save-option-heading">
<span aria-hidden="true">{slot}</span>
<div>
<p className="eyebrow">Local Save</p>
<h2>Slot {slot}</h2>
</div>
</div>
{editing ? (
<form className="character-create-form" onSubmit={(event) => createCharacter(event, slot)}>
<label>
Character Name
<input
autoFocus
maxLength={18}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
pattern="[A-Za-z][A-Za-z0-9 \-]{1,17}"
placeholder="Mira"
required
value={characterName}
/>
</label>
<small>
{save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'}
</small>
<div className="slot-save-actions">
<button
className={`save-continue-button ${save ? 'danger' : ''}`}
type="submit"
>
{save ? `Replace Slot ${slot}` : `Create in Slot ${slot}`}
</button>
<button
className="save-secondary-button"
onClick={() => setEditingSlot(null)}
type="button"
>
Cancel
</button>
</div>
</form>
) : save ? (
<>
<strong className="save-character-name">{save.character.name}</strong>
<SaveMetadata save={save} />
<div className="slot-save-actions">
<button
aria-label={`Continue ${save.character.name} in Slot ${slot}`}
className="save-continue-button"
onClick={() => continueLocal(slot)}
type="button"
>
Continue Offline
</button>
<button
aria-label={`Create new character in Slot ${slot}`}
className="save-secondary-button"
onClick={() => openCharacterEditor(slot)}
type="button"
>
New Character
</button>
</div>
</>
) : (
<>
<p className="save-empty-state">Empty slot</p>
<button
aria-label={`Create character in Slot ${slot}`}
className="save-continue-button"
onClick={() => openCharacterEditor(slot)}
type="button"
>
Create Character
</button>
</>
)}
</article>
)
})}
</div>
<section className="online-save-panel">
<div className="save-option-heading online-save-heading">
<span aria-hidden="true">O</span>
<div>
<p className="eyebrow">Account</p>
<h2>Online Save</h2>
</div>
</div>
{account ? (
<>
<div className="online-save-summary">
<strong className="save-character-name">
{onlineSave?.character.name ?? account.username}
</strong>
{onlineSave ? (
<SaveMetadata save={onlineSave} />
) : (
<p className="save-empty-state">
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
</p>
)}
</div>
<div className="auth-card">
<div className="auth-tabs">
<button
className={mode === 'login' ? 'selected' : ''}
onClick={() => {
setMode('login')
setMessage('')
<label className="online-save-destination">
Copy Into
<select
disabled={onlineBusy || !onlineSave}
onChange={(event) => {
setOnlineDestinationSlot(Number(event.target.value) as Iwt2LocalSaveSlot)
setConfirmOnlineDestination(null)
}}
value={onlineDestinationSlot}
>
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => (
<option key={slot} value={slot}>
Slot {slot} {localSaves[slot]?.character.name ?? 'Empty'}
</option>
))}
</select>
</label>
<div className="online-save-actions">
<button
className={`save-continue-button ${confirmOnlineDestination === onlineDestinationSlot ? 'danger' : ''}`}
disabled={onlineBusy || !onlineSave}
onClick={continueOnline}
type="button"
>
Sign In
{confirmOnlineDestination === onlineDestinationSlot
? `Confirm Replace Slot ${onlineDestinationSlot}`
: `Load Into Slot ${onlineDestinationSlot}`}
</button>
<button
className={mode === 'register' ? 'selected' : ''}
onClick={() => {
setMode('register')
setMessage('')
}}
className="save-secondary-button"
disabled={onlineBusy}
onClick={() => { void handleDifferentAccount() }}
type="button"
>
Create Account
Switch Account
</button>
</div>
<form onSubmit={submit}>
</>
) : (
<form className="online-save-login" onSubmit={signIn}>
<label>
Username
<input
@@ -106,23 +388,10 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
value={username}
/>
</label>
{mode === 'register' && (
<label>
Character Name
<input
autoComplete="nickname"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
required
value={characterName}
/>
</label>
)}
<label>
Password
<input
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
autoComplete="current-password"
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
@@ -131,62 +400,19 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
value={password}
/>
</label>
<button className="primary-button" disabled={busy} type="submit">
{busy
? 'Working...'
: mode === 'login'
? 'Enter Chronicle'
: 'Begin Adventure'}
<button className="save-continue-button" disabled={onlineBusy} type="submit">
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
</button>
</form>
<p className={`auth-message ${message ? 'error' : ''}`}>
{message || serverMessage || (
mode === 'register'
? 'The first account keeps the current local character and save.'
: 'Sign in to continue your character.'
)}
</p>
<div className="offline-divider"><span>or</span></div>
<section className="offline-entry">
<div>
<p className="eyebrow">Local Save</p>
<h2>Play Offline</h2>
<p>
No account or connection required. Offline progress stays on
this device.
</p>
</div>
{offlineCharacterExists && (
<button
className="offline-resume-button"
onClick={resumeOffline}
type="button"
>
Continue Offline Character
</button>
)}
<label>
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
<input
maxLength={20}
minLength={2}
onChange={(event) => setOfflineName(event.target.value)}
placeholder="Mira"
value={offlineName}
/>
</label>
<button
className="text-button offline-new-button"
onClick={beginOffline}
type="button"
>
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
</button>
</section>
</div>
<p
aria-live="polite"
className={`save-gateway-status ${messageIsError ? 'error' : ''}`}
>
{message}
</p>
</section>
</main>
)
+4
View File
@@ -1841,6 +1841,10 @@ export function selectOnlineMode() {
writeMode('online')
}
export function selectOfflineMode() {
writeMode('offline-local')
}
export function createOfflineCharacter(characterName: string): AuthSession {
const name = characterName.trim() || 'Mira'
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
+23 -3
View File
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
).some(isVisible)
}
function isDedicatedNavigationAction(action: InputAction) {
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
function dispatchGameAction(action: InputAction, device: InputDevice) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (
device === 'controller'
&& dedicatedNavAction
&& !keyboardInputRef.current
&& hasDedicatedGameNavigation()
) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device)
return
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateRight',
'confirm',
'back',
'pause',
] satisfies InputAction[]
const directTargetActions = [
'targetParty1',
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
const dedicatedNavigationActive = hasDedicatedGameNavigation() && (!combatActive || uiOverlay)
const action = dedicatedNavigationActive
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
?? (DPAD_NAV_ACTIONS[token] && isDedicatedNavigationAction(DPAD_NAV_ACTIONS[token])
? DPAD_NAV_ACTIONS[token]
: undefined)
: DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? DPAD_NAV_ACTIONS[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
@@ -1027,7 +1043,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
<div className="controller-keyboard-heading">
<div>
<p className="eyebrow">Controller Keyboard</p>
<strong>{keyboardInput.value || 'Enter text'}</strong>
<strong>
{keyboardInput.type === 'password'
? '•'.repeat(keyboardInput.value.length) || 'Enter password'
: keyboardInput.value || 'Enter text'}
</strong>
</div>
<button onClick={closeKeyboard} type="button">Done</button>
</div>
+79 -31
View File
@@ -22,11 +22,7 @@ import {
Iwt2RoguelikeUpgradeScreen,
Iwt2SettingsScreen,
} from './screens/Iwt2ShellScreens'
import {
loadIwt2Save,
writeIwt2Save,
type Iwt2Save,
} from './save/iwt2Repository'
import { writeIwt2Save, type Iwt2LocalSaveSlot, type Iwt2Save } from './save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { iwt2BossCoinRewardFor } from './content/bossRewards'
import {
@@ -46,6 +42,18 @@ import {
type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant,
} from './content/roguelike'
import {
createIwt2WeightedRoguelikeBossPair,
createUniformIwt2RoguelikeBossPair,
enabledIwt2RoguelikeBossPool,
IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED,
IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS,
IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED,
IWT2_PVP_STADIUM_ENABLED_BOSS_IDS,
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED,
} from './content/roguelikeBossProgression'
type Iwt2Screen =
| 'menu'
@@ -64,9 +72,11 @@ type Iwt2Screen =
const IWT2_MENU_COLUMNS = 2
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[]
bossesDefeated: number
buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[]
@@ -126,8 +136,8 @@ const MENU_ITEMS: Array<{
},
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Choose local, online, or fresh IWT2 progress.',
title: 'Cloud Backup',
description: 'Compare the active local slot with your online backup.',
glyph: 'C',
},
{
@@ -139,15 +149,19 @@ const MENU_ITEMS: Array<{
]
export function IWantToHeal2App({
initialSave,
localSlot,
onlineBackupsAvailable,
onBackToGameSelect,
onExitToSaveSelect,
}: {
initialSave: Iwt2Save
localSlot: Iwt2LocalSaveSlot
onlineBackupsAvailable: boolean
onBackToGameSelect: () => void
onExitToSaveSelect: () => void
}) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [save, setSave] = useState<Iwt2Save>(initialSave)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
@@ -162,8 +176,8 @@ export function IWantToHeal2App({
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
writeIwt2Save(save)
}, [save])
writeIwt2Save(save, localSlot)
}, [localSlot, save])
useEffect(() => () => {
cancelPvpQueueRef.current?.()
@@ -191,7 +205,7 @@ export function IWantToHeal2App({
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
onBackToGameSelect()
onExitToSaveSelect()
return
}
if (action === 'confirm') {
@@ -244,10 +258,13 @@ export function IWantToHeal2App({
buffs: roguelikeRun.buffs,
contentType: roguelikeRun.contentType,
debuffs: roguelikeRun.debuffs,
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
bossesDefeated: roguelikeRun.bossesDefeated,
onVictory: () => {
setRoguelikeRun((current) => current
? {
...current,
bossesDefeated: current.bossesDefeated + current.bossIds.length,
...buildRoguelikeChoices(save, current.variant),
}
: current)
@@ -271,7 +288,7 @@ export function IWantToHeal2App({
if (screen === 'roguelike-upgrade' && roguelikeRun) {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2RoguelikeUpgradeScreen
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
@@ -295,7 +312,7 @@ export function IWantToHeal2App({
if (screen === 'dungeons') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2DungeonsScreen
difficultySlug={selectedDungeonDifficultySlug}
save={save}
@@ -316,7 +333,7 @@ export function IWantToHeal2App({
if (screen === 'roguelike') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
@@ -346,7 +363,7 @@ export function IWantToHeal2App({
if (screen === 'raids') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2ModeScreen
difficultySlug={selectedRaidDifficultySlug}
mode="Raids"
@@ -380,7 +397,7 @@ export function IWantToHeal2App({
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
onExitToSaveSelect={onExitToSaveSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
@@ -394,7 +411,7 @@ export function IWantToHeal2App({
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2CustomizeCharacterScreen
save={save}
onBack={() => setScreen('menu')}
@@ -407,7 +424,7 @@ export function IWantToHeal2App({
if (screen === 'cloud-save') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2CloudSaveScreen
save={save}
onlineBackupsAvailable={onlineBackupsAvailable}
@@ -421,7 +438,7 @@ export function IWantToHeal2App({
if (screen === 'settings') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
</main>
)
@@ -429,7 +446,7 @@ export function IWantToHeal2App({
return (
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
@@ -522,7 +539,8 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
bossIds: createRandomRoguelikeBossPair(),
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
bossesDefeated: 0,
buffs: [],
contentType,
debuffs: [],
@@ -590,15 +608,45 @@ function applyRoguelikeChoice(
return {
...run,
...nextBase,
bossIds: createRandomRoguelikeBossPair(),
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
...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 createRoguelikeBossPair(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
stage: number,
bossesDefeated: number,
): Iwt2BossId[] {
const weightedProgressionEnabled = variant === 'pve'
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
: contentType === 'stadium'
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
const bossPool = roguelikeBossPoolFor(variant, contentType)
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
if (weightedProgressionEnabled) {
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
}
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count })
}
function roguelikeBossPoolFor(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
): Iwt2BossId[] | undefined {
if (variant !== 'pvp') return undefined
if (contentType === 'stadium') {
return IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED
? enabledIwt2RoguelikeBossPool(IWT2_PVP_STADIUM_ENABLED_BOSS_IDS)
: undefined
}
return IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED
? enabledIwt2RoguelikeBossPool(IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS)
: undefined
}
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
@@ -644,19 +692,19 @@ function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>)
function Iwt2Header({
onBack,
onBackToGameSelect,
onExitToSaveSelect,
save,
title,
}: {
onBack?: () => void
onBackToGameSelect: () => void
onExitToSaveSelect: () => void
save: Iwt2Save
title?: string
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
<button className="brand-button" onClick={onExitToSaveSelect} type="button">
<strong>Saves</strong>
</button>
{title && <strong className="iwt2-header-title">{title}</strong>}
<div className="character-summary">
+3
View File
@@ -1,11 +1,13 @@
export function ArenaBar({
className = '',
current,
label,
max,
shield = 0,
}: {
className?: string
current: number
label?: string
max: number
shield?: number
}) {
@@ -15,6 +17,7 @@ export function ArenaBar({
<div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
{label && <em>{label}</em>}
</div>
)
}
+1 -2
View File
@@ -46,9 +46,8 @@ export function PartyFrames({
<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} />
<ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)}
+2 -2
View File
@@ -220,11 +220,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
firePuddleRadius: 42,
firePuddleDamage: 15,
firePuddleSeconds: 8,
birdWaveThresholds: [0.9, 0.4],
birdWaveThresholds: [0.5],
birdFlightCooldown: 10,
birdFlightWindup: 0.85,
birdFlightSpeed: 360,
birdHealth: 100,
birdHealth: 55,
birdRadius: 16,
birdContactDamage: 26,
birdStunSeconds: 0.75,
+23 -4
View File
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
import type { Iwt2PlayerClassId } from './classes'
import { iwt2BossCoinRewardFor } from './bossRewards'
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
export type Iwt2PassiveInfusionId = Exclude<Iwt2RoguelikeSelfBuffId, 'revive-party-members'>
export type Iwt2GearStatId =
| 'maxHealth'
| 'moveSpeed'
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
export type Iwt2ClassGearProgress = {
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
infusionAbilityId: Iwt2InfusionAbilityId | null
passiveInfusionId: Iwt2PassiveInfusionId | null
}
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
@@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = {
}
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
export const IWT2_MAX_GEAR_LEVEL: Iwt2GearLevel = 10
export const IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 5
export const IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 10
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
weapon: 'Weapon',
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
}
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
return Object.values(progress.slots).some((slot) => slot.level >= 5)
return Object.values(progress.slots).some((slot) => slot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL)
}
export function isIwt2PassiveInfusionUnlocked(progress: Iwt2GearProgress): boolean {
return Object.values(progress).some((classProgress) => (
Object.values(classProgress.slots).some((slot) => slot.level >= IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL)
))
}
export function iwt2GearUpgradeCosts(
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
slotId: Iwt2GearSlotId,
currentLevel: Iwt2GearLevel,
): Iwt2GearUpgradeCost[] {
if (currentLevel >= 5) return []
if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
const slug = upgradeDifficultySlug(nextLevel)
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
]
return [
if (nextLevel === 5) return [
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
]
const overcap = nextLevel - 5
return [
{ itemId: primary.id, itemName: primary.name, quantity: 5 + overcap },
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 + overcap },
]
}
export function iwt2InfusionCosts(
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
feet: { level: 0 },
},
infusionAbilityId: null,
passiveInfusionId: null,
}
}
@@ -193,6 +211,7 @@ function slotRecipe(
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
if (level <= 2) return 'initiate'
if (level >= 6) return 'veteran'
if (level === 3) return 'veteran'
if (level === 4) return 'champion'
return 'mythic'
@@ -0,0 +1,37 @@
import { IWT2_PARTY_ORDER } from './classes'
import {
IWT2_GEAR_SLOTS,
type Iwt2GearLevel,
type Iwt2GearProgress,
} from './gear'
export type Iwt2PvpGearNormalizationConfig = {
enabled: boolean
gearLevel: Iwt2GearLevel
}
export const IWT2_PVP_NORMALIZED_GEAR_LEVEL: Iwt2GearLevel = 5
export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = {
enabled: true,
gearLevel: IWT2_PVP_NORMALIZED_GEAR_LEVEL,
}
export function createIwt2PvpNormalizedGearProgress(
config = IWT2_PVP_GEAR_NORMALIZATION,
): Iwt2GearProgress | undefined {
if (!config.enabled) return undefined
return Object.fromEntries(
IWT2_PARTY_ORDER.map((classId) => [
classId,
{
slots: Object.fromEntries(
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
),
infusionAbilityId: null,
passiveInfusionId: null,
},
]),
) as Iwt2GearProgress
}
@@ -0,0 +1,216 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
export const IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
export const IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
export const IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED = true
export const IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED = true
export const IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
export const IWT2_PVP_STADIUM_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS
type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
type TierWeight = {
tier: Iwt2RoguelikeBossTier
weight: number
}
type Iwt2RoguelikeBossPoolOptions = {
bossPool?: readonly Iwt2BossId[]
count?: number
}
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'],
mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'],
late: ['rimebastion', 'cinderback-ricochet', 'obsidian-ram-golem', 'venom-orchid-hydra', 'sandglass-scorpion'],
}
const IWT2_ROGUELIKE_BOSS_THREAT: Record<Iwt2RoguelikeBossTier, number> = {
early: 1,
mid: 2,
late: 3,
}
const IWT2_ROGUELIKE_BOSS_TIER_BY_ID: Record<Iwt2BossId, Iwt2RoguelikeBossTier> = {
bulldrome: 'early',
'yian-kut-ku': 'early',
'great-jaggi': 'early',
rathian: 'early',
'stormcoil-wyrm': 'early',
khezu: 'mid',
barroth: 'mid',
'tobi-kadachi': 'mid',
'ember-mantis-duelist': 'mid',
'crystal-bat-matriarch': 'mid',
'hollowcrown-revenant': 'mid',
rimebastion: 'late',
'cinderback-ricochet': 'late',
'obsidian-ram-golem': 'late',
'venom-orchid-hydra': 'late',
'sandglass-scorpion': 'late',
}
export function createIwt2PveRoguelikeBossPair(
stage: number,
random: () => number = Math.random,
): Iwt2BossId[] {
return createIwt2WeightedRoguelikeBossPair(stage, random)
}
export function createIwt2WeightedRoguelikeBossPair(
stage: number,
random: () => number = Math.random,
options: Iwt2RoguelikeBossPoolOptions = {},
): Iwt2BossId[] {
const choices: Iwt2BossId[] = []
const maxThreat = maxThreatForStage(stage)
const bossPool = normalizeBossPool(options.bossPool)
const count = normalizeBossCount(options.count)
while (choices.length < count) {
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
if (!next) break
choices.push(next)
}
return choices.length === count
? choices
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
}
export function createUniformIwt2RoguelikeBossPair(
random: () => number = Math.random,
options: Iwt2RoguelikeBossPoolOptions = {},
): Iwt2BossId[] {
const pool = normalizeBossPool(options.bossPool)
const count = normalizeBossCount(options.count)
const choices: Iwt2BossId[] = []
while (pool.length > 0 && choices.length < count) {
const index = randomIndex(pool.length, random)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
export function enabledIwt2RoguelikeBossPool(
bossIds: readonly Iwt2BossId[],
): Iwt2BossId[] {
return normalizeBossPool(bossIds)
}
function chooseWeightedBossForStage(
stage: number,
selected: readonly Iwt2BossId[],
maxThreat: number,
random: () => number,
bossPool: readonly Iwt2BossId[],
): Iwt2BossId | undefined {
const selectedSet = new Set(selected)
const bossPoolSet = new Set(bossPool)
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
const weightedTiers = tierWeightsForStage(stage)
.map((entry) => ({
...entry,
bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => (
bossPoolSet.has(bossId)
&& !selectedSet.has(bossId)
&& selectedThreat + threatForBoss(bossId) <= maxThreat
)),
}))
.filter((entry) => entry.weight > 0 && entry.bosses.length > 0)
const totalWeight = weightedTiers.reduce((total, entry) => total + entry.weight, 0)
if (totalWeight <= 0) return undefined
let roll = safeRandom(random) * totalWeight
for (const entry of weightedTiers) {
roll -= entry.weight
if (roll <= 0) {
return entry.bosses[randomIndex(entry.bosses.length, random)]
}
}
const lastEntry = weightedTiers[weightedTiers.length - 1]
return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)]
}
function normalizeBossPool(bossPool: readonly Iwt2BossId[] | undefined): Iwt2BossId[] {
const knownBosses = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
if (!bossPool) return knownBosses
const knownBossSet = new Set(knownBosses)
const normalized: Iwt2BossId[] = []
for (const bossId of bossPool) {
if (knownBossSet.has(bossId) && !normalized.includes(bossId)) {
normalized.push(bossId)
}
}
return normalized.length > 0 ? normalized : knownBosses
}
function tierWeightsForStage(stage: number): TierWeight[] {
const safeStage = Math.max(1, Math.floor(stage))
if (safeStage <= 2) {
return [
{ tier: 'early', weight: 85 },
{ tier: 'mid', weight: 15 },
{ tier: 'late', weight: 0 },
]
}
if (safeStage === 3) {
return [
{ tier: 'early', weight: 60 },
{ tier: 'mid', weight: 35 },
{ tier: 'late', weight: 5 },
]
}
if (safeStage <= 5) {
return [
{ tier: 'early', weight: 25 },
{ tier: 'mid', weight: 60 },
{ tier: 'late', weight: 15 },
]
}
if (safeStage === 6) {
return [
{ tier: 'early', weight: 10 },
{ tier: 'mid', weight: 50 },
{ tier: 'late', weight: 40 },
]
}
return [
{ tier: 'early', weight: 5 },
{ tier: 'mid', weight: 30 },
{ tier: 'late', weight: 65 },
]
}
function maxThreatForStage(stage: number): number {
const safeStage = Math.max(1, Math.floor(stage))
if (safeStage <= 2) return 3
if (safeStage === 3) return 4
if (safeStage <= 5) return 5
return 8
}
function threatForBoss(bossId: Iwt2BossId): number {
return IWT2_ROGUELIKE_BOSS_THREAT[IWT2_ROGUELIKE_BOSS_TIER_BY_ID[bossId]]
}
function randomIndex(length: number, random: () => number): number {
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
}
function normalizeBossCount(count: number | undefined): number {
return Math.max(1, Math.min(3, Math.floor(count ?? 2)))
}
function safeRandom(random: () => number): number {
const value = random()
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
}
+31 -3
View File
@@ -4,15 +4,24 @@ import type { MovementVector } from '../../../input'
import { BulldromeArenaScene } from './scenes/BulldromeArenaScene'
import type { Iwt2ArenaState } from '../sim/arenaState'
const IWT2_ARENA_FPS = 60
type PhaserArenaProps = {
active: boolean
movementRef: MutableRefObject<MovementVector>
selectedPartyIdRef: MutableRefObject<string>
stateRef: MutableRefObject<Iwt2ArenaState>
onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState
}
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
export function PhaserArena({ active, movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
const gameRef = useRef<Phaser.Game | null>(null)
const hostRef = useRef<HTMLDivElement | null>(null)
const onStepRef = useRef(onStep)
useEffect(() => {
onStepRef.current = onStep
}, [onStep])
useEffect(() => {
if (!hostRef.current) return undefined
@@ -21,7 +30,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
getMovement: () => movementRef.current,
getSelectedPartyId: () => selectedPartyIdRef.current,
getState: () => stateRef.current,
step: onStep,
step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
})
const game = new Phaser.Game({
type: Phaser.AUTO,
@@ -30,17 +39,36 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
height: initialState.bounds.height,
backgroundColor: '#10141b',
pixelArt: false,
fps: {
target: IWT2_ARENA_FPS,
limit: IWT2_ARENA_FPS,
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene,
})
gameRef.current = game
return () => {
if (gameRef.current === game) gameRef.current = null
game.destroy(true)
}
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
}, [movementRef, selectedPartyIdRef, stateRef])
useEffect(() => {
const game = gameRef.current
if (!game?.loop.started) return
if (active) {
if (!game.loop.running) {
game.loop.resetDelta()
game.loop.wake()
}
return
}
game.loop.sleep()
}, [active])
return <div className="iwt2-phaser-host" ref={hostRef} />
}
+47 -47
View File
@@ -52,20 +52,17 @@ const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionPro
const WARM_TINT = 0xffc88a
const IMPACT_TINT = 0xff9b5f
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion {
export function bossRenderMotion(
entity: Iwt2BossEntityState,
timeSeconds: number,
motion: BossRenderMotion = createBossRenderMotion(),
): BossRenderMotion {
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
const phase = String(entity.attackPhase)
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const moving = speed > 8 || phase === 'relocating'
const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: BossRenderMotion = {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}
resetBossRenderMotion(motion)
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
@@ -76,12 +73,10 @@ export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: numbe
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
return {
...motion,
y: -Math.max(0, wave) * profile.idleBob,
scaleY: 1 + Math.max(0, wave) * 0.025,
scaleX: 1 - Math.max(0, wave) * 0.01,
}
motion.y = -Math.max(0, wave) * profile.idleBob
motion.scaleY = 1 + Math.max(0, wave) * 0.025
motion.scaleX = 1 - Math.max(0, wave) * 0.01
return motion
}
function movingMotion(
@@ -94,13 +89,11 @@ function movingMotion(
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
const lift = Math.abs(stride) * profile.moveBob
return {
...motion,
y: -lift,
scaleX: 1 + 0.012 * speedFactor,
scaleY: 1 - 0.009 * Math.abs(stride),
rotation: facingX * 0.025 * speedFactor,
}
motion.y = -lift
motion.scaleX = 1 + 0.012 * speedFactor
motion.scaleY = 1 - 0.009 * Math.abs(stride)
motion.rotation = facingX * 0.025 * speedFactor
return motion
}
function windupMotion(
@@ -112,28 +105,24 @@ function windupMotion(
): BossRenderMotion {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
return {
...motion,
x: -facingX * (isSlam ? 1.5 : 3),
y: isSlam ? -2 : 2,
scaleX: 1.035,
scaleY: 0.965,
rotation: -facingX * 0.02,
tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined,
}
motion.x = -facingX * (isSlam ? 1.5 : 3)
motion.y = isSlam ? -2 : 2
motion.scaleX = 1.035
motion.scaleY = 0.965
motion.rotation = -facingX * 0.02
motion.tint = pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined
return motion
}
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
return {
...motion,
x: facingX * 3 + shake,
y: Math.cos(timeSeconds * Math.PI * 42) * 1.2,
scaleX: 1.045,
scaleY: 0.96,
rotation: facingX * 0.018,
tint: WARM_TINT,
}
motion.x = facingX * 3 + shake
motion.y = Math.cos(timeSeconds * Math.PI * 42) * 1.2
motion.scaleX = 1.045
motion.scaleY = 0.96
motion.rotation = facingX * 0.018
motion.tint = WARM_TINT
return motion
}
function recoverMotion(
@@ -145,13 +134,24 @@ function recoverMotion(
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
const pop = profile.impactScale * impactPulse
return {
...motion,
y: -2 * impactPulse,
scaleX: 1 + pop,
scaleY: 1 + pop * 0.45,
tint: impactPulse > 0.45 ? IMPACT_TINT : undefined,
}
motion.y = -2 * impactPulse
motion.scaleX = 1 + pop
motion.scaleY = 1 + pop * 0.45
motion.tint = impactPulse > 0.45 ? IMPACT_TINT : undefined
return motion
}
function createBossRenderMotion(): BossRenderMotion {
return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
}
function resetBossRenderMotion(motion: BossRenderMotion) {
motion.x = 0
motion.y = 0
motion.scaleX = 1
motion.scaleY = 1
motion.rotation = 0
motion.tint = undefined
}
function isActionPhase(phase: string, suffix: string): boolean {
+61 -43
View File
@@ -27,65 +27,59 @@ export function partyRenderMotion(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
motion: PartyRenderMotion = createPartyRenderMotion(),
): PartyRenderMotion {
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: PartyRenderMotion = {
x: 0,
y: 0,
rotation: 0,
scaleX: 1,
scaleY: 1,
}
resetPartyRenderMotion(motion)
if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 }
if (entity.health <= 0) {
motion.rotation = facingX * 0.08
motion.scaleY = 0.92
return motion
}
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 }
motion.y = 3
motion.rotation = -facingX * 0.12
motion.scaleY = 0.9
return motion
}
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
if (attackProgress < 1) {
const strike = Math.sin(attackProgress * Math.PI)
const ranged = entity.projectileSpeed > 0
return {
...motion,
x: facingX * (ranged ? -3 : 4) * strike,
y: -1.5 * strike,
rotation: facingX * (ranged ? -0.05 : 0.09) * strike,
scaleX: 1 + (ranged ? 0.012 : 0.035) * strike,
scaleY: 1 - (ranged ? 0.006 : 0.018) * strike,
tint: ranged ? 0xdff3ff : 0xffe1a6,
}
motion.x = facingX * (ranged ? -3 : 4) * strike
motion.y = -1.5 * strike
motion.rotation = facingX * (ranged ? -0.05 : 0.09) * strike
motion.scaleX = 1 + (ranged ? 0.012 : 0.035) * strike
motion.scaleY = 1 - (ranged ? 0.006 : 0.018) * strike
motion.tint = ranged ? 0xdff3ff : 0xffe1a6
return motion
}
if (entity.castSecondsRemaining > 0) {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
return {
...motion,
y: -1 - pulse * 1.5,
scaleX: 1 + pulse * 0.012,
scaleY: 1 + pulse * 0.012,
tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff,
}
motion.y = -1 - pulse * 1.5
motion.scaleX = 1 + pulse * 0.012
motion.scaleY = 1 + pulse * 0.012
motion.tint = entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff
return motion
}
if (speed > 10) {
const stride = Math.sin(timeSeconds * Math.PI * 9)
const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
return {
...motion,
y: -lift,
rotation: facingX * 0.025 * Math.min(1, speed / 190),
scaleY: 1 - Math.abs(stride) * 0.01,
}
motion.y = -lift
motion.rotation = facingX * 0.025 * Math.min(1, speed / 190)
motion.scaleY = 1 - Math.abs(stride) * 0.01
return motion
}
const idle = Math.sin(timeSeconds * Math.PI * 2.2)
return {
...motion,
y: -Math.max(0, idle) * 0.9,
scaleY: 1 + Math.max(0, idle) * 0.008,
}
motion.y = -Math.max(0, idle) * 0.9
motion.scaleY = 1 + Math.max(0, idle) * 0.008
return motion
}
export function isPartyAttackPulseActive(
@@ -101,17 +95,41 @@ export function partyWeaponLayerMotion(
layer: Iwt2ClassWeaponLayer,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
motion: PartyWeaponLayerMotion = createPartyWeaponLayerMotion(),
): PartyWeaponLayerMotion {
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
resetPartyWeaponLayerMotion(motion)
if (!attackPulse || entity.health <= 0) return motion
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
if (progress >= 1) return motion
const strike = Math.sin(progress * Math.PI)
const attackMotion = layer.attackMotion
return {
offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike,
offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike,
rotation: (attackMotion?.rotation ?? 0) * strike,
}
motion.offsetXScale = (attackMotion?.offsetXScale ?? 0) * strike
motion.offsetYScale = (attackMotion?.offsetYScale ?? 0) * strike
motion.rotation = (attackMotion?.rotation ?? 0) * strike
return motion
}
function createPartyRenderMotion(): PartyRenderMotion {
return { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 }
}
function resetPartyRenderMotion(motion: PartyRenderMotion) {
motion.x = 0
motion.y = 0
motion.rotation = 0
motion.scaleX = 1
motion.scaleY = 1
motion.tint = undefined
}
function createPartyWeaponLayerMotion(): PartyWeaponLayerMotion {
return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
}
function resetPartyWeaponLayerMotion(motion: PartyWeaponLayerMotion) {
motion.offsetXScale = 0
motion.offsetYScale = 0
motion.rotation = 0
}
function attackProgressFor(
@@ -2,12 +2,14 @@ import Phaser from 'phaser'
import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
import { bossRenderMotion } from '../bossAnimation'
import { bossRenderMotion, type BossRenderMotion } from '../bossAnimation'
import {
isPartyAttackPulseActive,
partyRenderMotion,
partyWeaponLayerMotion,
type PartyAttackPulse,
type PartyRenderMotion,
type PartyWeaponLayerMotion,
} from '../partyAnimation'
import type {
Iwt2ArenaEvent,
@@ -41,10 +43,15 @@ type PartyLayerSprites = {
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
private drawableEntities: DrawableEntity[] = []
private arenaGraphics?: Phaser.GameObjects.Graphics
private bossUnderlayGraphics?: Phaser.GameObjects.Graphics
private entityGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private liveBossIds = new Set<string>()
private liveLabelIds = new Set<string>()
private livePartyIds = new Set<string>()
private liveProjectileIds = new Set<string>()
private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private partySprites = new Map<string, Phaser.GameObjects.Image>()
@@ -54,11 +61,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
private livePartyEffectIds = new Set<string>()
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
private currentEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private liveEntityAnchorIds = new Set<string>()
private bossMotionScratch: BossRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
private partyMotionScratch: PartyRenderMotion = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 }
private weaponMotionScratch: PartyWeaponLayerMotion = { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
private lastPartyAnimationEventId = 0
private lastFloatingEventId = 0
private lastStateTime = 0
private lastHudPublish = 0
constructor(deps: SceneDeps) {
super('bulldrome-arena')
@@ -66,7 +77,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
preload() {
for (const metadata of Object.values(IWT2_BOSS_METADATA)) {
const encounterBossIds = new Set(this.deps.getState().bosses.map((boss) => boss.bossId))
for (const bossId of encounterBossIds) {
const metadata = IWT2_BOSS_METADATA[bossId]
if (metadata.spriteUrl.endsWith('.svg')) {
this.load.svg(bossSpriteKey(metadata.id), metadata.spriteUrl, { width: 144, height: 144 })
} else {
@@ -92,7 +105,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.telegraphGraphics = this.add.graphics().setDepth(10)
this.bossUnderlayGraphics = this.add.graphics().setDepth(20)
this.entityGraphics = this.add.graphics().setDepth(35)
this.drawState(this.deps.getState())
const state = this.deps.getState()
this.drawArena(state)
this.drawState(state)
}
update(_: number, delta: number) {
@@ -102,13 +117,11 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawState(state: Iwt2ArenaState) {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state)
this.updatePartyAnimations(state)
this.drawEntities(state)
this.drawProjectiles(state)
this.drawFloatingCombatTexts(state)
this.lastHudPublish += 1
}
private drawArena(state: Iwt2ArenaState) {
@@ -140,12 +153,30 @@ export class BulldromeArenaScene extends Phaser.Scene {
bossUnderlay.clear()
graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
const liveLabelIds = new Set<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id))
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
const livePartyIds = new Set<string>(state.party.map((member) => member.id))
const entities = this.drawableEntities
const liveBossIds = this.liveBossIds
const liveLabelIds = this.liveLabelIds
const livePartyIds = this.livePartyIds
entities.length = 0
liveBossIds.clear()
liveLabelIds.clear()
livePartyIds.clear()
for (const boss of state.bosses) {
entities.push(boss)
liveBossIds.add(boss.id)
if (shouldDrawLabel(boss)) liveLabelIds.add(boss.id)
}
for (const add of state.hostileAdds) {
entities.push(add)
if (shouldDrawLabel(add)) liveLabelIds.add(add.id)
}
for (const member of state.party) {
entities.push(member)
livePartyIds.add(member.id)
if (shouldDrawLabel(member)) liveLabelIds.add(member.id)
}
for (const entity of entities.sort(entitySort)) {
for (const entity of entities) {
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)
@@ -232,8 +263,10 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawProjectiles(state: Iwt2ArenaState) {
const graphics = this.entityGraphics!
const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id))
const liveProjectileIds = this.liveProjectileIds
liveProjectileIds.clear()
for (const projectile of state.projectiles) {
liveProjectileIds.add(projectile.id)
const spriteKey = projectileSpriteKey(projectile.projectileKind)
if (this.textures.exists(spriteKey)) {
let sprite = this.projectileSprites.get(projectile.id)
@@ -244,7 +277,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x)
const size = projectileDisplaySize(projectile.projectileKind, projectile.radius)
sprite
.setTexture(spriteKey)
.setPosition(projectile.position.x, projectile.position.y)
.setRotation(velocityAngle)
.setDisplaySize(size.width, size.height)
@@ -280,14 +312,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
const motion = bossRenderMotion(entity, timeSeconds)
const motion = bossRenderMotion(entity, timeSeconds, this.bossMotionScratch)
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite
.setTexture(key)
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
.setDisplaySize(width * motion.scaleX, height * motion.scaleY)
.setRotation(rotation + motion.rotation)
@@ -314,10 +345,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch)
applyMotionTint(sprites.body, motion.tint)
sprites.body
.setTexture(classArenaBodySpriteKey(entity.classId))
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
.setRotation(motion.rotation)
@@ -335,7 +365,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
const flipX = entity.facing.x < -0.05
const facingSign = flipX ? -1 : 1
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
const layerMotion = partyWeaponLayerMotion(
entity,
layer,
timeSeconds,
this.partyAttackPulses.get(entity.id),
this.weaponMotionScratch,
)
const layerHeight = entity.radius * layer.heightScale
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
@@ -358,7 +394,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
})
applyMotionTint(sprite, motion.tint)
sprite
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth, displayHeight)
@@ -420,7 +455,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
accent
.setVisible(true)
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
@@ -446,14 +480,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
const displayHeight = entity.radius * 4.55
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id), this.partyMotionScratch)
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite
.setTexture(key)
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
@@ -541,6 +574,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
private drawFloatingCombatTexts(state: Iwt2ArenaState) {
const currentEntityAnchors = this.currentEntityAnchors
updateEntityAnchors(currentEntityAnchors, this.liveEntityAnchorIds, state)
if (state.time < this.lastStateTime) {
this.lastFloatingEventId = 0
for (const text of this.floatingCombatTexts.values()) text.destroy()
@@ -557,17 +592,21 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
continue
}
this.spawnFloatingCombatText(event, state)
this.spawnFloatingCombatText(event, currentEntityAnchors)
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
}
this.lastStateTime = state.time
this.lastEntityAnchors = entityAnchors(state)
this.currentEntityAnchors = this.lastEntityAnchors
this.lastEntityAnchors = currentEntityAnchors
}
private spawnFloatingCombatText(event: Iwt2ArenaEvent, state: Iwt2ArenaState) {
private spawnFloatingCombatText(
event: Iwt2ArenaEvent,
currentEntityAnchors: Map<string, FloatingCombatTextAnchor>,
) {
const anchor = event.targetId
? entityAnchors(state).get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
? currentEntityAnchors.get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
: undefined
if (!anchor || !event.value || event.value <= 0) return
@@ -630,11 +669,6 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
}
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'
@@ -710,16 +744,38 @@ function shouldSpawnFloatingCombatText(event: Iwt2ArenaEvent): boolean {
&& event.targetId !== undefined
}
function entityAnchors(state: Iwt2ArenaState): Map<string, FloatingCombatTextAnchor> {
const anchors = new Map<string, FloatingCombatTextAnchor>()
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) {
function updateEntityAnchors(
anchors: Map<string, FloatingCombatTextAnchor>,
liveIds: Set<string>,
state: Iwt2ArenaState,
) {
liveIds.clear()
for (const entity of state.party) updateEntityAnchor(anchors, liveIds, entity)
for (const entity of state.hostileAdds) updateEntityAnchor(anchors, liveIds, entity)
for (const entity of state.bosses) updateEntityAnchor(anchors, liveIds, entity)
for (const id of anchors.keys()) {
if (!liveIds.has(id)) anchors.delete(id)
}
}
function updateEntityAnchor(
anchors: Map<string, FloatingCombatTextAnchor>,
liveIds: Set<string>,
entity: DrawableEntity,
) {
liveIds.add(entity.id)
const anchor = anchors.get(entity.id)
if (!anchor) {
anchors.set(entity.id, {
kind: entity.kind,
position: { ...entity.position },
position: entity.position,
radius: entity.radius,
})
return
}
return anchors
anchor.kind = entity.kind
anchor.position = entity.position
anchor.radius = entity.radius
}
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
@@ -792,9 +848,7 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
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)
drawLaneDangerPath(graphics, indicator.start, indicator.end, indicator.width)
return
}
@@ -805,27 +859,25 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
}
if (indicator.kind === 'cone') {
const points = coneDangerPolygon(
drawConeDangerPath(
graphics,
indicator.origin,
indicator.direction,
indicator.range,
indicator.angleRadians,
)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
if (indicator.kind === 'arc') {
const points = arcDangerPolygon(
drawArcDangerPath(
graphics,
indicator.position,
indicator.direction,
indicator.innerRadius,
indicator.outerRadius,
indicator.angleRadians,
)
graphics.fillPoints(points, true)
graphics.strokePoints(points, true)
return
}
@@ -835,7 +887,8 @@ function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2Are
graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius)
}
function arcDangerPolygon(
function drawArcDangerPath(
graphics: Phaser.GameObjects.Graphics,
position: Iwt2Vec2,
direction: Iwt2Vec2,
innerRadius: number,
@@ -845,43 +898,50 @@ function arcDangerPolygon(
const baseAngle = Math.atan2(direction.y, direction.x)
const halfAngle = angleRadians / 2
const segmentCount = 18
const points: Phaser.Math.Vector2[] = []
graphics.beginPath()
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
position.x + Math.cos(angle) * outerRadius,
position.y + Math.sin(angle) * outerRadius,
))
const x = position.x + Math.cos(angle) * outerRadius
const y = position.y + Math.sin(angle) * outerRadius
if (index === 0) graphics.moveTo(x, y)
else graphics.lineTo(x, y)
}
for (let index = segmentCount; index >= 0; index -= 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
graphics.lineTo(
position.x + Math.cos(angle) * innerRadius,
position.y + Math.sin(angle) * innerRadius,
))
)
}
return points
graphics.closePath().fillPath().strokePath()
}
function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) {
function drawLaneDangerPath(
graphics: Phaser.GameObjects.Graphics,
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),
]
const normalX = (-dy / length) * halfWidth
const normalY = (dx / length) * halfWidth
graphics
.beginPath()
.moveTo(start.x + normalX, start.y + normalY)
.lineTo(end.x + normalX, end.y + normalY)
.lineTo(end.x - normalX, end.y - normalY)
.lineTo(start.x - normalX, start.y - normalY)
.closePath()
.fillPath()
.strokePath()
}
function coneDangerPolygon(
function drawConeDangerPath(
graphics: Phaser.GameObjects.Graphics,
origin: Iwt2Vec2,
direction: Iwt2Vec2,
range: number,
@@ -889,15 +949,15 @@ function coneDangerPolygon(
) {
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
graphics.beginPath().moveTo(origin.x, origin.y)
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = baseAngle - halfAngle + angleRadians * t
points.push(new Phaser.Math.Vector2(
graphics.lineTo(
origin.x + Math.cos(angle) * range,
origin.y + Math.sin(angle) * range,
))
)
}
return points
graphics.closePath().fillPath().strokePath()
}
+104 -16
View File
@@ -10,13 +10,17 @@ import {
import type { Iwt2BossId } from '../content/bosses'
import {
createDefaultIwt2GearProgress,
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
IWT2_GEAR_SLOTS,
IWT2_MAX_GEAR_LEVEL,
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
isIwt2PassiveInfusionUnlocked,
type Iwt2GearLevel,
type Iwt2GearProgress,
type Iwt2GearSlotId,
type Iwt2PassiveInfusionId,
} from '../content/gear'
import {
IWT2_INFUSION_ABILITIES,
@@ -97,14 +101,20 @@ export type Iwt2OnlineSaveResult = {
save: Iwt2Save | null
}
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
export const IWT2_LOCAL_SAVE_SLOTS = [1, 2, 3] as const
export type Iwt2LocalSaveSlot = (typeof IWT2_LOCAL_SAVE_SLOTS)[number]
export type Iwt2LocalSaveSlots = Record<Iwt2LocalSaveSlot, Iwt2Save | null>
export function createDefaultIwt2Save(): Iwt2Save {
const IWT2_LEGACY_SAVE_KEY = 'i-want-to-heal-2:save:v1'
const IWT2_SAVE_MIGRATION_KEY = 'i-want-to-heal-2:save-slots:migrated:v1'
const IWT2_ACTIVE_SAVE_SLOT_KEY = 'i-want-to-heal-2:active-save-slot:v1'
export function createDefaultIwt2Save(characterName = 'Healer'): Iwt2Save {
return {
version: 2,
updatedAt: Date.now(),
character: {
name: 'Healer',
name: normalizeCharacterName(characterName),
level: 1,
experience: 0,
healerStyle: 'dawnweaver',
@@ -126,7 +136,11 @@ function normalizeSave(value: unknown): Iwt2Save {
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
return {
version: 2,
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
updatedAt: typeof candidate.updatedAt === 'number'
&& Number.isFinite(candidate.updatedAt)
&& candidate.updatedAt > 0
? candidate.updatedAt
: Date.now(),
character: {
name: candidate.character?.name || 'Healer',
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
@@ -145,11 +159,51 @@ function normalizeSave(value: unknown): Iwt2Save {
}
}
export function loadIwt2Save(): Iwt2Save {
function iwt2SaveSlotKey(slot: Iwt2LocalSaveSlot) {
return `i-want-to-heal-2:save:slot-${slot}:v1`
}
function migrateLegacyIwt2Save() {
if (window.localStorage.getItem(IWT2_SAVE_MIGRATION_KEY)) return
const legacySave = window.localStorage.getItem(IWT2_LEGACY_SAVE_KEY)
const firstSlotKey = iwt2SaveSlotKey(1)
if (legacySave && !window.localStorage.getItem(firstSlotKey)) {
window.localStorage.setItem(firstSlotKey, legacySave)
}
window.localStorage.setItem(IWT2_SAVE_MIGRATION_KEY, 'true')
}
export function activeIwt2SaveSlot(): Iwt2LocalSaveSlot {
const stored = Number(window.localStorage.getItem(IWT2_ACTIVE_SAVE_SLOT_KEY))
return IWT2_LOCAL_SAVE_SLOTS.includes(stored as Iwt2LocalSaveSlot)
? stored as Iwt2LocalSaveSlot
: 1
}
export function selectActiveIwt2SaveSlot(slot: Iwt2LocalSaveSlot) {
window.localStorage.setItem(IWT2_ACTIVE_SAVE_SLOT_KEY, String(slot))
}
export function loadIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save {
return loadExistingIwt2Save(slot) ?? createDefaultIwt2Save()
}
export function loadExistingIwt2Save(slot = activeIwt2SaveSlot()): Iwt2Save | null {
migrateLegacyIwt2Save()
const serialized = window.localStorage.getItem(iwt2SaveSlotKey(slot))
if (!serialized) return null
try {
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
return normalizeSave(JSON.parse(serialized))
} catch {
return createDefaultIwt2Save()
return null
}
}
export function loadIwt2SaveSlots(): Iwt2LocalSaveSlots {
return {
1: loadExistingIwt2Save(1),
2: loadExistingIwt2Save(2),
3: loadExistingIwt2Save(3),
}
}
@@ -171,11 +225,12 @@ export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSav
}
}
export function writeIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
...save,
updatedAt: Date.now(),
}))
export function writeIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
replaceIwt2Save(save, slot)
}
export function replaceIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
window.localStorage.setItem(iwt2SaveSlotKey(slot), JSON.stringify(normalizeSave(save)))
}
export function recordIwt2BossKill(
@@ -263,7 +318,7 @@ export function upgradeIwt2GearSlot(
): Iwt2Save {
const classProgress = save.gearProgress[classId]
const slot = classProgress.slots[slotId]
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
if (slot.level >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`)
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
const inventory = spendInventoryCosts(save.inventory, costs)
return {
@@ -294,8 +349,10 @@ export function setIwt2InfusionAbility(
const classProgress = save.gearProgress[classId]
const ability = IWT2_INFUSION_ABILITIES[abilityId]
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) {
throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} gear slot to anchor the infusion cost.`)
}
if (classProgress.infusionAbilityId === abilityId) return save
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
return {
@@ -312,6 +369,27 @@ export function setIwt2InfusionAbility(
}
}
export function setIwt2PassiveInfusion(
save: Iwt2Save,
passiveInfusionId: Iwt2PassiveInfusionId,
): Iwt2Save {
if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) {
throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`)
}
if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save
return {
...save,
updatedAt: Date.now(),
gearProgress: {
...save.gearProgress,
healer: {
...save.gearProgress.healer,
passiveInfusionId,
},
},
}
}
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
}
@@ -450,6 +528,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
const classProgress = rawClassProgress as {
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
infusionAbilityId?: unknown
passiveInfusionId?: unknown
}
for (const slotId of IWT2_GEAR_SLOTS) {
next[classId].slots[slotId] = {
@@ -461,6 +540,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
&& isIwt2InfusionUnlocked(next[classId])
? infusionAbilityId as Iwt2InfusionAbilityId
: null
next[classId].passiveInfusionId = classId === 'healer'
&& isIwt2PassiveInfusionUnlocked(next)
? asPassiveInfusionId(classProgress.passiveInfusionId)
: null
}
return next
}
@@ -470,10 +553,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
}
function asGearLevel(value: unknown): Iwt2GearLevel {
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0)))
return level as Iwt2GearLevel
}
function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null {
if (typeof value !== 'string' || value === 'revive-party-members') return null
return value as Iwt2PassiveInfusionId
}
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
if (!Array.isArray(value)) return []
const byId = new Map<string, Iwt2InventoryItem>()
+112 -70
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
@@ -12,9 +12,8 @@ import {
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim'
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKillReward,
type Iwt2BossDropAward,
@@ -45,25 +44,37 @@ import {
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
} from '../content/roguelike'
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
const PhaserArena = lazy(() => import('../render/PhaserArena').then((module) => ({
default: module.PhaserArena,
})))
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type PvpResultReason = 'opponent-defeated' | null
type OverlayAction = 'primary' | 'requeue' | 'menu'
type OverlayNavEntry = {
action: OverlayAction
row: number
column: number
}
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'menu', row: 1 },
{ action: 'primary', row: 0, column: 0 },
{ action: 'menu', row: 1, column: 0 },
]
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'requeue', row: 1 },
{ action: 'menu', row: 2 },
{ action: 'primary', row: 0, column: 0 },
{ action: 'requeue', row: 0, column: 1 },
{ action: 'menu', row: 0, column: 2 },
]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
const IWT2_HUD_PUBLISH_INTERVAL_SECONDS = 0.1
type BossArenaScreenProps = {
bossId: Iwt2BossId
@@ -76,9 +87,11 @@ type BossArenaScreenProps = {
onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
bossesDefeated: number
buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[]
greenCoinThreshold: number
onVictory: () => void
stage: number
variant: Iwt2RoguelikeVariant
@@ -88,7 +101,12 @@ type BossArenaScreenProps = {
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp'
const pvpNormalizedGearProgress = useMemo(() => createIwt2PvpNormalizedGearProgress(), [])
const activeGearProgress = pvpRoguelike
? pvpNormalizedGearProgress
: roguelikeRun?.variant !== 'pvp'
? save.gearProgress
: undefined
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
@@ -103,6 +121,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
const arenaBounds = useMemo(() => createTopScreenArenaBounds(), [])
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
bossId,
bossIds,
@@ -110,21 +129,26 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedDamageScale,
roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined,
activeGearProgress,
arenaBounds,
))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike
? createInitialIwt2ArenaState(
? createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
EMPTY_ROGUELIKE_BUFFS,
createPressureState(roguelikeStage, roguelikeContentType),
activeGearProgress,
arenaBounds,
)
: null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [pvpResultReason, setPvpResultReason] = useState<PvpResultReason>(null)
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
@@ -137,7 +161,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
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 {
@@ -149,14 +172,6 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => {
statusRef.current = status
}, [status])
@@ -182,20 +197,24 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedDamageScale,
roguelikeBuffs,
pressureState,
pveGearActive ? save.gearProgress : undefined,
activeGearProgress,
arenaBounds,
)
const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState(
? createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
EMPTY_ROGUELIKE_BUFFS,
pressureState,
activeGearProgress,
arenaBounds,
)
: null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
lastPublishTimeRef.current = next.time
stateRef.current = next
opponentStateRef.current = nextOpponentState
setArenaState(next)
@@ -204,16 +223,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
setDropAwards([])
setPetAwards([])
setSelectedOverlayAction('primary')
setPvpResultReason(null)
setStatus('playing')
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>, nextPvpResultReason: PvpResultReason = null) => {
setSelectedOverlayAction('primary')
setPvpResultReason(nextPvpResultReason)
statusRef.current = nextStatus
setStatus(nextStatus)
}, [])
const resumeArena = useCallback(() => {
setPvpResultReason(null)
statusRef.current = 'playing'
setStatus('playing')
}, [])
@@ -222,18 +244,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
() => {
const baseAbilities = abilitiesForHealer(
save.character.healerStyle,
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
activeGearProgress?.healer.infusionAbilityId ?? null,
)
const gearAbilities = pveGearActive
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
const gearAbilities = activeGearProgress
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
: baseAbilities
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
? [activeGearProgress.healer.passiveInfusionId]
: []
return applyRoguelikeModifiers(
gearAbilities,
roguelikeRun?.buffs ?? [],
[...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
roguelikeRun?.debuffs ?? [],
)
},
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
[activeGearProgress, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
@@ -248,7 +273,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
}
abilityCooldownsRef.current = nextCooldowns
stateRef.current = result.state
lastHudSignatureRef.current = arenaHudSignature(result.state)
lastPublishTimeRef.current = result.state.time
setAbilityCooldowns(nextCooldowns)
setArenaState(result.state)
}, [])
@@ -259,12 +284,18 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const active = entries.find((entry) => entry.action === current) ?? entries[0]
const candidates = 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
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
if (action === 'navigateUp') return entry.column === active.column && entry.row < active.row
if (action === 'navigateDown') return entry.column === active.column && 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))
candidates.sort((a, b) => {
const aDistance = Math.abs(a.row - active.row) + Math.abs(a.column - active.column)
const bDistance = Math.abs(b.row - active.row) + Math.abs(b.column - active.column)
return aDistance - bDistance
})
return candidates[0]?.action ?? current
})
}, [pvpRoguelike])
@@ -305,7 +336,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action === 'back') {
if (statusRef.current === 'paused') resumeArena()
if (device === 'pc' && statusRef.current === 'paused') resumeArena()
return
}
}
@@ -337,6 +368,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action.startsWith('targetParty')) {
if (statusRef.current !== 'playing') return
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
@@ -375,7 +407,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
difficultySlug,
difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
experienceMultiplier,
})
updatedSave = reward.save
@@ -389,25 +421,26 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
if (pvpRoguelike && roguelikeRun) {
const playerCleared = next.bosses.every((boss) => boss.health <= 0)
const playerDefeated = next.party.every((member) => member.health <= 0)
const opponentCleared = Boolean(nextOpponentState?.bosses.every((boss) => boss.health <= 0))
const opponentDefeated = Boolean(nextOpponentState?.party.every((member) => member.health <= 0))
if (!pvpRoguelike && playerCleared) {
showOverlay('victory')
} else if (pvpRoguelike && playerDefeated) {
showOverlay('defeat')
} else if (pvpRoguelike && playerCleared && opponentDefeated) {
showOverlay('victory', 'opponent-defeated')
} else if (pvpRoguelike && playerCleared && opponentCleared && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else {
showOverlay('victory')
}
} else if (next.party.every((member) => member.health <= 0)) {
} else if (!pvpRoguelike && playerDefeated) {
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
const fightEnded = statusRef.current !== 'playing'
if (fightEnded || next.time - lastPublishTimeRef.current >= IWT2_HUD_PUBLISH_INTERVAL_SECONDS) {
lastPublishTimeRef.current = next.time
setArenaState(next)
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
@@ -433,7 +466,9 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
? 'Rematch'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossTitle} Down`
? pvpResultReason === 'opponent-defeated'
? 'CPU Rival Falls'
: `${bossTitle} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
@@ -501,12 +536,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
targetBindings={targetBindings}
/>
<Suspense fallback={<div className="iwt2-phaser-host iwt2-arena-loading" role="status">Loading arena...</div>}>
<PhaserArena
active={status === 'playing'}
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
</Suspense>
{status === 'paused' && (
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
<div>
@@ -643,31 +681,22 @@ function roguelikeBossHealthScale(stage: number): number {
return 1 + Math.max(0, stage - 1) * 0.1
}
function rewardDifficultySlugForKill(
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
defeatedEarlierThisArena: number,
fallbackSlug: string,
): string {
if (!roguelikeRun) return fallbackSlug
return roguelikeRun.bossesDefeated + defeatedEarlierThisArena >= roguelikeRun.greenCoinThreshold
? 'veteran'
: fallbackSlug
}
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
return DEFAULT_OVERLAY_NAV_ENTRIES
}
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)
@@ -701,6 +730,7 @@ function createArenaState(
buffs: Iwt2RoguelikeSelfBuffId[],
roguelikePressure: ReturnType<typeof createPressureState>,
gearProgress?: Iwt2Save['gearProgress'],
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
const baseState = createInitialIwt2ArenaState(
bossId,
@@ -708,6 +738,7 @@ function createArenaState(
bossHealthScale,
partyDamageTakenScale,
roguelikePressure,
bounds,
)
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
@@ -723,6 +754,17 @@ function createArenaState(
}
}
function createTopScreenArenaBounds(): Iwt2ArenaBounds {
if (typeof window === 'undefined') return { width: 960, height: 540 }
const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH
&& window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT
const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH
return {
width: Math.max(320, Math.round(window.innerWidth - railWidth)),
height: Math.max(240, Math.round(window.innerHeight)),
}
}
function createPressureState(
stage: number | undefined,
contentType: Iwt2RoguelikeContentType | undefined,
+162 -83
View File
@@ -9,9 +9,9 @@ import {
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository'
import {
createDefaultIwt2Save,
loadIwt2OnlineSave,
setIwt2InfusionAbility,
setIwt2PassiveInfusion,
updateIwt2CharacterSettings,
upgradeIwt2GearSlot,
canAffordIwt2Costs,
@@ -40,6 +40,8 @@ import {
IWT2_HEALER_ORDER,
} from '../content/healerAbilities'
import {
buildIwt2SelfBuffChoices,
IWT2_REVIVE_PARTY_CHOICE,
type Iwt2RoguelikeChoice,
type Iwt2RoguelikeContentType,
type Iwt2RoguelikeOpponentDebuffId,
@@ -51,9 +53,13 @@ import {
IWT2_GEAR_SLOT_RECIPES,
IWT2_GEAR_SLOTS,
IWT2_GEAR_STAT_LABELS,
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
IWT2_MAX_GEAR_LEVEL,
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
isIwt2PassiveInfusionUnlocked,
type Iwt2PassiveInfusionId,
type Iwt2GearStatId,
type Iwt2GearSlotId,
} from '../content/gear'
@@ -144,6 +150,7 @@ type Iwt2GearNavEntry =
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
| { kind: 'passiveInfusion', key: string, row: number, column: number, passiveId: Iwt2PassiveInfusionId, disabled: boolean }
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
const IWT2_NAME_MAX_LENGTH = 18
@@ -1256,7 +1263,7 @@ export function Iwt2RoguelikeUpgradeScreen({
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
return
}
if (action === 'back' || action === 'pause') return
if (action === 'back') onBack()
})
return (
@@ -1547,33 +1554,10 @@ export function Iwt2HunterProfileScreen({
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
const nextIndex = moveHunterProfileSelection(navEntries, selectedIndex, action)
const nextEntry = navEntries[nextIndex]
setSelectedIndex(nextIndex)
if (nextEntry?.kind === 'item') setFocusedItemKey(nextEntry.itemKey)
}
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
@@ -1863,6 +1847,7 @@ function Iwt2ProfileNameEditor({
<label>
<span>Profile Name</span>
<input
data-controller-nav="skip"
maxLength={IWT2_NAME_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
value={draft}
@@ -1901,38 +1886,42 @@ export function Iwt2CloudSaveScreen({
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
const [message, setMessage] = useState('')
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
setSyncingOnlineSave(true)
setMessage('Checking online save...')
try {
const result = await loadIwt2OnlineSave()
if (isCancelled()) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
} catch (reason) {
if (isCancelled()) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
} finally {
if (!isCancelled()) setSyncingOnlineSave(false)
}
}, [])
useEffect(() => {
let cancelled = false
if (!onlineBackupsAvailable) {
setOnlineSave(null)
setMessage('Online save unavailable in offline mode.')
return () => {
cancelled = true
}
}
setSyncingOnlineSave(true)
setMessage('Checking online save...')
loadIwt2OnlineSave()
.then((result) => {
if (cancelled) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
})
.catch((reason) => {
if (cancelled) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
})
.finally(() => {
if (!cancelled) setSyncingOnlineSave(false)
})
const timer = window.setTimeout(() => {
void checkOnlineSave(() => cancelled)
}, 0)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [onlineBackupsAvailable])
}, [checkOnlineSave, onlineBackupsAvailable])
const useLocalSave = useCallback(async () => {
const handleUseLocalSave = useCallback(async () => {
if (!onlineBackupsAvailable) {
setMessage('Using local save. Sign in online to update the server copy.')
return
@@ -1950,16 +1939,11 @@ export function Iwt2CloudSaveScreen({
}
}, [onlineBackupsAvailable, save])
const useOnlineSave = useCallback(() => {
if (!onlineSave) return
const handleUseOnlineSave = useCallback(() => {
if (!onlineBackupsAvailable || !onlineSave) return
onSaveUpdated(onlineSave)
setMessage('Local save now uses online progress.')
}, [onlineSave, onSaveUpdated])
const useNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
@@ -1969,7 +1953,7 @@ export function Iwt2CloudSaveScreen({
value: onlineBackupsAvailable ? 'Upload' : 'Current',
disabled: syncingOnlineSave,
onConfirm: () => {
void useLocalSave()
void handleUseLocalSave()
},
},
{
@@ -1977,30 +1961,22 @@ export function Iwt2CloudSaveScreen({
label: 'Use Online Save',
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
value: onlineSave ? 'Download' : 'Empty',
disabled: syncingOnlineSave || !onlineSave,
onConfirm: useOnlineSave,
},
{
key: 'new-save',
label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New',
onConfirm: useNewSave,
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
onConfirm: handleUseOnlineSave,
},
], onBack), [
handleUseLocalSave,
handleUseOnlineSave,
onlineBackupsAvailable,
onlineSave,
onBack,
save,
syncingOnlineSave,
useLocalSave,
useNewSave,
useOnlineSave,
])
return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
{message && <p className="iwt2-screen-note">{message}</p>}
<Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
@@ -2023,16 +1999,22 @@ export function Iwt2GearUpgradeScreen({
const selectedSlot = classProgress.slots[selectedSlotId]
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
const nextLevel = Math.min(5, selectedSlot.level + 1)
const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
const selectedClassName = gearClassDisplayName(selectedClassId, save)
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
const canUpgrade = selectedSlot.level < IWT2_MAX_GEAR_LEVEL && canAffordIwt2Costs(save, upgradeCosts)
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
const infusionAnchorSlot = selectedSlot.level >= 5
const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
? selectedSlotId
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) ?? selectedSlotId
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
const passiveInfusionChoices = useMemo<Array<Iwt2RoguelikeChoice<Iwt2PassiveInfusionId>>>(() => {
if (selectedClassId !== 'healer') return []
return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle))
.filter((choice): choice is Iwt2RoguelikeChoice<Iwt2PassiveInfusionId> => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id)
}, [save.character.healerStyle, selectedClassId])
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
IWT2_PARTY_ORDER.forEach((classId, index) => {
@@ -2054,8 +2036,19 @@ export function Iwt2GearUpgradeScreen({
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
})
})
passiveInfusionChoices.forEach((choice, index) => {
const selected = classProgress.passiveInfusionId === choice.id
entries.push({
kind: 'passiveInfusion',
key: `passive:${choice.id}`,
row: infusionAbilities.length + index + 1,
column: 2,
passiveId: choice.id,
disabled: selected || !passiveInfusionUnlocked,
})
})
return entries
}, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId])
}, [canUpgrade, classProgress.infusionAbilityId, classProgress.passiveInfusionId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, passiveInfusionChoices, passiveInfusionUnlocked, save, selectedClassId])
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
@@ -2107,6 +2100,18 @@ export function Iwt2GearUpgradeScreen({
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
}
return
}
if (entry.kind === 'passiveInfusion') {
if (entry.disabled) return
try {
const nextSave = setIwt2PassiveInfusion(save, entry.passiveId)
onSaveUpdated(nextSave)
const passiveName = passiveInfusionChoices.find((choice) => choice.id === entry.passiveId)?.name ?? 'Passive'
setMessage(`${passiveName} set as passive infusion.`)
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Passive infusion failed.')
}
}
}
@@ -2114,12 +2119,23 @@ export function Iwt2GearUpgradeScreen({
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
<div className="iwt2-gear-layout">
<section className="iwt2-gear-column">
<button
className={`back-button iwt2-gear-back ${activeEntry?.kind === 'back' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={activeEntry?.kind === 'back' ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'back')}
type="button"
>
Back
</button>
<div className="iwt2-gear-class-list">
{IWT2_PARTY_ORDER.map((classId) => {
const metadata = IWT2_CLASS_METADATA[classId]
const selected = selectedClassId === classId
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
const classInfusion = save.gearProgress[classId].infusionAbilityId
const passiveInfusion = classId === 'healer' ? save.gearProgress.healer.passiveInfusionId : null
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
const className = gearClassDisplayName(classId, save)
const classSubtitle = gearClassSubtitle(classId, save)
@@ -2137,7 +2153,7 @@ export function Iwt2GearUpgradeScreen({
<div>
<strong>{className}</strong>
<small>{classSubtitle}</small>
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
<small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
</div>
</button>
)
@@ -2180,7 +2196,7 @@ export function Iwt2GearUpgradeScreen({
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
type="button"
>
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
</button>
</section>
@@ -2196,8 +2212,8 @@ export function Iwt2GearUpgradeScreen({
<small>{selectedBonus.text}</small>
</span>
<span>
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
<strong>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
<small>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
</span>
</div>
<div className="iwt2-gear-cost-list">
@@ -2235,12 +2251,40 @@ export function Iwt2GearUpgradeScreen({
<div>
<strong>{ability.name}</strong>
<small>{ability.description}</small>
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small>
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}</small>
</div>
</button>
)
})}
</div>
{selectedClassId === 'healer' && (
<div className="iwt2-infusion-list">
{passiveInfusionChoices.map((choice) => {
const selected = classProgress.passiveInfusionId === choice.id
const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id
const disabled = selected || !passiveInfusionUnlocked
return (
<button
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={focused ? 'true' : undefined}
disabled={disabled}
key={choice.id}
onClick={() => activateEntry({ kind: 'passiveInfusion', key: `passive:${choice.id}`, row: 0, column: 2, passiveId: choice.id, disabled })}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `passive:${choice.id}`)}
type="button"
>
<span>P</span>
<div>
<strong>{choice.name}</strong>
<small>{choice.description}</small>
<small>{selected ? 'Selected passive' : passiveInfusionUnlocked ? 'Passive infusion' : `Unlock: any gear slot to +${IWT2_MAX_GEAR_LEVEL}`}</small>
</div>
</button>
)
})}
</div>
)}
<footer className="iwt2-gear-message">{message}</footer>
</section>
</div>
@@ -2284,7 +2328,9 @@ function gearBonus(label: string, value: string): { label: string, text: string,
}
function infusionAnchorText(level: number): string {
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
if (level >= IWT2_MAX_GEAR_LEVEL) return 'Active and passive infusion anchors available.'
if (level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) return 'Active infusion anchor available.'
return 'No infusion anchor.'
}
function formatPercent(value: number): string {
@@ -2372,6 +2418,39 @@ function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
return entries[Math.min(index, entries.length - 1)]
}
function moveHunterProfileSelection(
entries: Iwt2HunterProfileNavEntry[],
current: number,
action: InputAction,
) {
if (!action.startsWith('navigate') || entries.length === 0) return current
const bounded = Math.min(current, entries.length - 1)
const active = entries[bounded]
if (!active) return 0
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
}
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
+47 -16
View File
@@ -9,6 +9,7 @@ import type {
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2ArenaBounds,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
@@ -66,14 +67,15 @@ export function createInitialIwt2ArenaState(
bossHealthScale = 1,
partyDamageTakenScale = 1,
roguelikePressure?: Iwt2RoguelikePressureState,
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
const initialBossIds = bossIds?.length ? [...bossIds] : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, initialBossIds.length, bossHealthScale, bounds))
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
bounds,
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)),
projectiles: [],
hostileAdds: [],
hazards: [],
@@ -95,9 +97,15 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
function createBossEntity(
bossId: Iwt2BossId,
index: number,
bossCount: number,
healthScale: number,
bounds: Iwt2ArenaBounds,
): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index)
const position = scaleArenaPoint(initialBossPosition(index, bossCount), bounds)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return {
id: bossId,
@@ -123,9 +131,9 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds),
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds),
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
@@ -134,13 +142,27 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
}
}
function initialBossPosition(index: number) {
function initialBossPosition(index: number, bossCount = 2) {
if (bossCount > 2) {
const angle = -Math.PI * 0.5 + (Math.PI * (index + 0.5)) / bossCount
return {
x: 690 + Math.cos(angle) * 120,
y: DEFAULT_ARENA_HEIGHT * 0.5 + Math.sin(angle) * 165,
}
}
return {
x: index === 0 ? 660 : 760,
y: index === 0 ? 190 : 345,
}
}
function scaleArenaPoint(point: Iwt2Vec2, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
return {
x: point.x * bounds.width / DEFAULT_ARENA_WIDTH,
y: point.y * bounds.height / DEFAULT_ARENA_HEIGHT,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4
@@ -175,7 +197,7 @@ function initialBossSecondaryCooldown(bossId: Iwt2BossId): number {
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] }
if (step <= 0) return state
const inputMove = {
x: clampInputAxis(input.moveX),
@@ -261,10 +283,18 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
nextAddId: bossResult.nextAddId ?? state.nextAddId,
nextHazardId: projectileResult.nextHazardId,
nextEventId: state.nextEventId + nextEvents.length,
events: [...state.events, ...nextEvents].slice(-MAX_EVENTS),
events: appendEventHistory(state.events, nextEvents),
}
}
function appendEventHistory(previous: Iwt2ArenaEvent[], next: Iwt2ArenaEvent[]): Iwt2ArenaEvent[] {
if (next.length === 0) return previous
const overflow = Math.max(0, previous.length + next.length - MAX_EVENTS)
return overflow > 0
? [...previous.slice(overflow), ...next]
: [...previous, ...next]
}
function tickPartyHotEffects(
party: Iwt2PartyEntityState[],
dt: number,
@@ -324,7 +354,7 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
})
}
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number, bounds: Iwt2ArenaBounds): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
return {
@@ -332,7 +362,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds),
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
@@ -355,7 +385,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
attackReady: false,
status: createEmptyStatus(),
hotEffects: [],
preferredOffset: { ...initial.preferredOffset },
preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds),
decisionSecondsRemaining: initial.decisionOffset,
}
}
@@ -748,6 +778,7 @@ function advanceBossProjectile({
radius: hitMember.radius + projectile.radius,
}, {
damage: projectile.damage,
damageEventType: 'bossProjectileHit',
sourceId: projectile.sourceId,
time,
})
@@ -765,13 +796,13 @@ function advanceBossProjectile({
bounced = true
}
if (bounced) {
if (hitMember) {
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,
position: hitMember.position,
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
sourceId: projectile.sourceId,
time,
+6 -133
View File
@@ -1,59 +1,16 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA } from '../content/classes'
import type { Iwt2BossId } from '../content/bosses'
import {
createInitialIwt2ArenaState as createCoreIwt2ArenaState,
tickIwt2Arena as tickCoreIwt2Arena,
} from './arena'
import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaBounds,
Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2RoguelikePressureState,
} 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 type Iwt2ArenaState = Iwt2CoreArenaState
export function createInitialIwt2ArenaState(
bossId?: Iwt2BossId,
@@ -61,95 +18,11 @@ export function createInitialIwt2ArenaState(
bossHealthScale?: number,
partyDamageTakenScale?: number,
roguelikePressure?: Iwt2RoguelikePressureState,
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
return createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds)
}
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 []
})
return tickCoreIwt2Arena(state, input, dt)
}
+1
View File
@@ -224,6 +224,7 @@ export function tickGroundHazards({
},
{
damage: hazard.damage,
damageEventType: 'groundHazardTick',
sourceId: hazard.sourceId,
time,
},
+4 -2
View File
@@ -12,9 +12,11 @@ const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale(
_stage: number,
_contentType: Iwt2RoguelikePressureContentType,
stage: number,
contentType: Iwt2RoguelikePressureContentType,
): number {
void stage
void contentType
return 1
}
+2
View File
@@ -288,6 +288,8 @@ export type Iwt2ArenaEventType =
| 'partyAttack'
| 'partyHealed'
| 'partyDamaged'
| 'bossProjectileHit'
| 'groundHazardTick'
| 'partyStunned'
| 'bossChargeStart'
| 'bossChargeHit'
+1 -1
View File
@@ -46,7 +46,7 @@ const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 26
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_FIREBALL_BOUNCES = 3
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36
const YIAN_CENTER_CHARGE_SPEED = 430