Compare commits

...
8 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
32 changed files with 3467 additions and 768 deletions
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 113
versionName "1.1.32"
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.
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()
+499
View File
@@ -236,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;
}
@@ -1873,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;
+27 -141
View File
@@ -1,156 +1,42 @@
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>
<h1>Loading...</h1>
</section>
</main>
<Suspense fallback={<Iwt2LaunchFallback />}>
<LazyIWantToHeal2App
initialSave={launch.save}
localSlot={launch.localSlot}
onlineBackupsAvailable={launch.onlineBackupsAvailable}
onExitToSaveSelect={() => setLaunch(null)}
/>
</Suspense>
)
}
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 <AuthScreen onContinue={setLaunch} />
}
function Iwt2LaunchFallback() {
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>
<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>
)
+379 -153
View File
@@ -1,192 +1,418 @@
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.
</p>
<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>
<div className="auth-card">
<div className="auth-tabs">
<button
className={mode === 'login' ? 'selected' : ''}
onClick={() => {
setMode('login')
setMessage('')
}}
type="button"
>
Sign In
</button>
<button
className={mode === 'register' ? 'selected' : ''}
onClick={() => {
setMode('register')
setMessage('')
}}
type="button"
>
Create Account
</button>
<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>
<form onSubmit={submit}>
<label>
Username
<input
autoComplete="username"
maxLength={20}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={username}
/>
</label>
{mode === 'register' && (
{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>
<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"
>
{confirmOnlineDestination === onlineDestinationSlot
? `Confirm Replace Slot ${onlineDestinationSlot}`
: `Load Into Slot ${onlineDestinationSlot}`}
</button>
<button
className="save-secondary-button"
disabled={onlineBusy}
onClick={() => { void handleDifferentAccount() }}
type="button"
>
Switch Account
</button>
</div>
</>
) : (
<form className="online-save-login" onSubmit={signIn}>
<label>
Character Name
Username
<input
autoComplete="nickname"
autoComplete="username"
maxLength={20}
minLength={2}
onChange={(event) => setCharacterName(event.target.value)}
minLength={3}
onChange={(event) => setUsername(event.target.value)}
pattern="[A-Za-z0-9_]+"
required
value={characterName}
value={username}
/>
</label>
)}
<label>
Password
<input
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<button className="primary-button" disabled={busy} type="submit">
{busy
? 'Working...'
: mode === 'login'
? 'Enter Chronicle'
: 'Begin Adventure'}
</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
<label>
Password
<input
autoComplete="current-password"
maxLength={128}
minLength={10}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<button className="save-continue-button" disabled={onlineBusy} type="submit">
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
</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>
</form>
)}
</section>
<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)) {
+5 -1
View File
@@ -1043,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>
+59 -30
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 {
@@ -49,6 +45,11 @@ import {
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,
@@ -71,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[]
@@ -133,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',
},
{
@@ -146,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')
@@ -169,8 +176,8 @@ export function IWantToHeal2App({
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
writeIwt2Save(save)
}, [save])
writeIwt2Save(save, localSlot)
}, [localSlot, save])
useEffect(() => () => {
cancelPvpQueueRef.current?.()
@@ -198,7 +205,7 @@ export function IWantToHeal2App({
useGameAction((action, device) => {
if (screen !== 'menu' || device !== 'controller') return
if (action === 'back') {
onBackToGameSelect()
onExitToSaveSelect()
return
}
if (action === 'confirm') {
@@ -251,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)
@@ -278,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)}
@@ -302,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}
@@ -323,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}
@@ -353,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"
@@ -387,7 +397,7 @@ export function IWantToHeal2App({
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
onExitToSaveSelect={onExitToSaveSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
@@ -401,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')}
@@ -414,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}
@@ -428,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>
)
@@ -436,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">
@@ -529,7 +539,8 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
bossIds: createRoguelikeBossPair(variant, contentType, 1),
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
bossesDefeated: 0,
buffs: [],
contentType,
debuffs: [],
@@ -597,7 +608,7 @@ function applyRoguelikeChoice(
return {
...run,
...nextBase,
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
@@ -607,17 +618,35 @@ 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)
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
}
return createUniformIwt2RoguelikeBossPair()
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[] {
@@ -663,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">
+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
}
@@ -3,6 +3,12 @@ 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'
@@ -11,6 +17,11 @@ type TierWeight = {
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'],
@@ -52,25 +63,32 @@ export function createIwt2PveRoguelikeBossPair(
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 < 2) {
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random)
while (choices.length < count) {
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
if (!next) break
choices.push(next)
}
return choices.length === 2
return choices.length === count
? choices
: createUniformIwt2RoguelikeBossPair(random)
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
}
export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.random): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
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 < 2) {
while (pool.length > 0 && choices.length < count) {
const index = randomIndex(pool.length, random)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
@@ -78,19 +96,29 @@ export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.r
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) => (
!selectedSet.has(bossId) && selectedThreat + threatForBoss(bossId) <= maxThreat
bossPoolSet.has(bossId)
&& !selectedSet.has(bossId)
&& selectedThreat + threatForBoss(bossId) <= maxThreat
)),
}))
.filter((entry) => entry.weight > 0 && entry.bosses.length > 0)
@@ -110,6 +138,21 @@ function chooseWeightedBossForStage(
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) {
@@ -152,7 +195,7 @@ function maxThreatForStage(stage: number): number {
if (safeStage <= 2) return 3
if (safeStage === 3) return 4
if (safeStage <= 5) return 5
return 6
return 8
}
function threatForBoss(bossId: Iwt2BossId): number {
@@ -163,6 +206,10 @@ 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>()
+97 -78
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 {
@@ -14,7 +14,6 @@ import {
} from '../sim/arenaState'
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKillReward,
type Iwt2BossDropAward,
@@ -45,22 +44,29 @@ 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
@@ -68,6 +74,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
@@ -80,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
@@ -92,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
@@ -115,23 +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[]>([])
@@ -144,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 {
@@ -156,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])
@@ -189,22 +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)
@@ -213,16 +223,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
setDropAwards([])
setPetAwards([])
setSelectedOverlayAction('primary')
setPvpResultReason(null)
setStatus('playing')
}, [arenaBounds, 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')
}, [])
@@ -231,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) => {
@@ -257,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)
}, [])
@@ -268,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])
@@ -314,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
}
}
@@ -385,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
@@ -399,25 +421,26 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
if (pvpRoguelike && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else {
showOverlay('victory')
}
} else if (next.party.every((member) => member.health <= 0)) {
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 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)
@@ -443,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'
@@ -511,12 +536,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
targetBindings={targetBindings}
/>
<PhaserArena
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
<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>
@@ -653,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)
+80 -26
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
@@ -1938,11 +1945,6 @@ export function Iwt2CloudSaveScreen({
setMessage('Local save now uses online progress.')
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
const handleUseNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'use-local',
@@ -1962,16 +1964,8 @@ export function Iwt2CloudSaveScreen({
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
onConfirm: handleUseOnlineSave,
},
{
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: handleUseNewSave,
},
], onBack), [
handleUseLocalSave,
handleUseNewSave,
handleUseOnlineSave,
onlineBackupsAvailable,
onlineSave,
@@ -1981,7 +1975,7 @@ export function Iwt2CloudSaveScreen({
])
return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
<Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
@@ -2005,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) => {
@@ -2036,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]
@@ -2089,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.')
}
}
}
@@ -2112,6 +2135,7 @@ export function Iwt2GearUpgradeScreen({
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)
@@ -2129,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>
)
@@ -2172,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>
@@ -2188,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">
@@ -2227,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>
@@ -2276,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 {
+31 -9
View File
@@ -69,8 +69,8 @@ export function createInitialIwt2ArenaState(
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, bounds))
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,
@@ -97,9 +97,15 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
return [primaryBossId, random]
}
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number, bounds: Iwt2ArenaBounds): Iwt2BossEntityState {
function createBossEntity(
bossId: Iwt2BossId,
index: number,
bossCount: number,
healthScale: number,
bounds: Iwt2ArenaBounds,
): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = scaleArenaPoint(initialBossPosition(index), bounds)
const position = scaleArenaPoint(initialBossPosition(index, bossCount), bounds)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return {
id: bossId,
@@ -136,7 +142,14 @@ 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,
@@ -184,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),
@@ -270,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,
@@ -757,6 +778,7 @@ function advanceBossProjectile({
radius: hitMember.radius + projectile.radius,
}, {
damage: projectile.damage,
damageEventType: 'bossProjectileHit',
sourceId: projectile.sourceId,
time,
})
@@ -774,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,
+4 -133
View File
@@ -1,60 +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,
@@ -64,94 +20,9 @@ export function createInitialIwt2ArenaState(
roguelikePressure?: Iwt2RoguelikePressureState,
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
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,
},
+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