Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90930b71aa | ||
|
|
4766a34cd3 | ||
|
|
8ffa6db317 | ||
|
|
1fa1c8c070 | ||
|
|
802dadc7f3 | ||
|
|
d61cee55ed | ||
|
|
679900e7f5 | ||
|
|
ff64c6ddcc | ||
|
|
a6cca2810c | ||
|
|
f48e6130b8 | ||
|
|
61de17d63e | ||
|
|
5629041879 | ||
|
|
ed3f43c93f | ||
|
|
d70941f43e | ||
|
|
de2f86e101 |
@@ -15,6 +15,9 @@
|
|||||||
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
||||||
- Focus state must always be visible and predictable when controller navigation is active.
|
- Focus state must always be visible and predictable when controller navigation is active.
|
||||||
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
||||||
|
- Controller navigation fixes must not break live combat bindings. When changing shared input routing, verify that combat-only actions still win during active combat: movement, ability casts, party targeting, pause, and overlay navigation must all work on AYN Thor controls.
|
||||||
|
- `data-game-nav-active` and other custom navigation markers may coexist with live combat HUDs. Do not treat those markers as menu mode during active combat unless a pause/dialog/result overlay is visible.
|
||||||
|
- Before considering any controller input change complete, test both states: no-overlay combat and menu/dialog navigation. No-overlay combat must include at least one successful ability cast from a controller binding and continuous analog movement on the AYN Thor control scheme.
|
||||||
|
|
||||||
## Architecture Requirements
|
## Architecture Requirements
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
||||||
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
||||||
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
||||||
|
- IWT2 combat must preserve AYN Thor face/shoulder button ability bindings even when party frames, HUD controls, or target rails use custom controller navigation state. Menu navigation priority must never swallow ability inputs while `data-combat-active="true"` and no overlay is open.
|
||||||
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
|
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
|
||||||
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
|
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
|
||||||
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
|
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
|
||||||
|
|||||||
@@ -54,12 +54,18 @@ code updates to be a Git pull plus app restart.
|
|||||||
|
|
||||||
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
||||||
|
|
||||||
Repository:
|
Repository URL:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://git.whoagland.com/phenom/i-want-to-heal.git
|
https://git.whoagland.com/phenom/i-want-to-heal.git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Local Gitea repository path on the same TrueNAS host:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
|
||||||
|
```
|
||||||
|
|
||||||
TrueNAS paths:
|
TrueNAS paths:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -67,12 +73,15 @@ TrueNAS paths:
|
|||||||
/mnt/usbssds/apps/iwanttoheal/data
|
/mnt/usbssds/apps/iwanttoheal/data
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the app directory and clone the repo:
|
Create the app directory and clone the repo. When Gitea is hosted on the same
|
||||||
|
TrueNAS box, prefer the local repository path instead of HTTPS. This avoids
|
||||||
|
Git-over-HTTPS TLS failures while downloading large pack files.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
||||||
cd /mnt/usbssds/apps/iwanttoheal
|
cd /mnt/usbssds/apps/iwanttoheal
|
||||||
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
|
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
|
||||||
|
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git app
|
||||||
```
|
```
|
||||||
|
|
||||||
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
||||||
@@ -101,6 +110,25 @@ cd /mnt/usbssds/apps/iwanttoheal/app
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If setting up a different app hosted by the same Gitea instance, first find its
|
||||||
|
bare repository path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo find /mnt -type d -name "REPO-NAME.git" -prune -print 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Then substitute that path in the `safe.directory` and `git clone` commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git
|
||||||
|
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git app
|
||||||
|
sudo chown -R truenas_admin:truenas_admin app
|
||||||
|
```
|
||||||
|
|
||||||
|
`safe.directory` is a Git trust exception for the Gitea-owned bare repository.
|
||||||
|
It is needed because Gitea's repository files are owned by the app/container
|
||||||
|
user, not by `truenas_admin`.
|
||||||
|
|
||||||
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
||||||
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
||||||
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 105
|
versionCode 120
|
||||||
versionName "1.1.26"
|
versionName "1.1.39"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -96,8 +96,18 @@ curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$REL
|
|||||||
-F "attachment=@$APK"
|
-F "attachment=@$APK"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the CLI release fails after creating the Gitea release but before uploading the APK asset, retry only the release upload:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run release:cli -- --release-only 1.1.26
|
||||||
|
```
|
||||||
|
|
||||||
## Step 5: Update TrueNAS
|
## Step 5: Update TrueNAS
|
||||||
|
|
||||||
|
The TrueNAS app checkout should be cloned from the local Gitea bare repository
|
||||||
|
path when Gitea runs on the same TrueNAS host. After that setup, normal updates
|
||||||
|
still use `git pull`; the remote is local filesystem storage instead of HTTPS.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /mnt/usbssds/apps/iwanttoheal/app
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
git pull
|
git pull
|
||||||
|
|||||||
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -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
|
||||||
|
}
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -153,6 +154,48 @@ def gitea_request(
|
|||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
data = exc.read()
|
data = exc.read()
|
||||||
return exc.code, data
|
return exc.code, data
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]:
|
||||||
|
curl = shutil.which("curl")
|
||||||
|
if curl is None:
|
||||||
|
raise ReleaseError("curl is required for Gitea asset uploads")
|
||||||
|
|
||||||
|
url = config.gitea_url.rstrip("/") + path
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
curl,
|
||||||
|
"--silent",
|
||||||
|
"--show-error",
|
||||||
|
"--retry",
|
||||||
|
"3",
|
||||||
|
"--retry-all-errors",
|
||||||
|
"--retry-delay",
|
||||||
|
"2",
|
||||||
|
"--request",
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
"--header",
|
||||||
|
f"Authorization: token {config.token}",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{apk_path}",
|
||||||
|
"--write-out",
|
||||||
|
"\n%{http_code}",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
|
||||||
|
raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
|
||||||
|
try:
|
||||||
|
body, status_text = proc.stdout.rsplit(b"\n", 1)
|
||||||
|
return int(status_text), body
|
||||||
|
except ValueError as exc:
|
||||||
|
output = proc.stdout.decode("utf-8", "replace")
|
||||||
|
raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc
|
||||||
|
|
||||||
|
|
||||||
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
||||||
@@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N
|
|||||||
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
||||||
release_id = find_or_create_release(config, log)
|
release_id = find_or_create_release(config, log)
|
||||||
|
|
||||||
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
|
|
||||||
header = (
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
|
|
||||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
|
||||||
).encode("utf-8")
|
|
||||||
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
|
||||||
body = header + apk_path.read_bytes() + footer
|
|
||||||
asset_name = urllib.parse.quote(apk_path.name)
|
asset_name = urllib.parse.quote(apk_path.name)
|
||||||
path = (
|
path = (
|
||||||
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||||
f"/releases/{release_id}/assets?name={asset_name}"
|
f"/releases/{release_id}/assets?name={asset_name}"
|
||||||
)
|
)
|
||||||
status, data = gitea_request(
|
status, data = gitea_upload_asset(config, path, apk_path)
|
||||||
config,
|
|
||||||
"POST",
|
|
||||||
path,
|
|
||||||
body=body,
|
|
||||||
content_type=f"multipart/form-data; boundary={boundary}",
|
|
||||||
)
|
|
||||||
if status == 409:
|
if status == 409:
|
||||||
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
||||||
parse_json_response(status, data, "Upload release asset")
|
parse_json_response(status, data, "Upload release asset")
|
||||||
|
|||||||
@@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip the RESET confirmation when used with --reset-player-data.",
|
help="Skip the RESET confirmation when used with --reset-player-data.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--release-only",
|
||||||
|
metavar="VERSION",
|
||||||
|
help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.yes_reset and not args.reset_player_data:
|
if args.yes_reset and not args.reset_player_data:
|
||||||
parser.error("--yes-reset requires --reset-player-data")
|
parser.error("--yes-reset requires --reset-player-data")
|
||||||
|
if args.release_only and args.reset_player_data:
|
||||||
|
parser.error("--release-only cannot be combined with --reset-player-data")
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) -
|
|||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", "replace")
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
||||||
boundary = "----iwanttoheal-release-boundary"
|
url = GITEA_URL + path
|
||||||
body = (
|
curl = shutil.which("curl")
|
||||||
f"--{boundary}\r\n"
|
if curl is None:
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
|
raise SystemExit("curl is required for Gitea asset uploads")
|
||||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
|
||||||
).encode("utf-8")
|
proc = subprocess.run(
|
||||||
body += apk.read_bytes()
|
[
|
||||||
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
curl,
|
||||||
headers = {
|
"--silent",
|
||||||
"Authorization": f"token {token}",
|
"--show-error",
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
"--retry",
|
||||||
}
|
"3",
|
||||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
|
"--retry-all-errors",
|
||||||
|
"--retry-delay",
|
||||||
|
"2",
|
||||||
|
"--request",
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
"--header",
|
||||||
|
f"Authorization: token {token}",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{apk}",
|
||||||
|
"--write-out",
|
||||||
|
"\n%{http_code}",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = proc.stderr.strip() or proc.stdout.strip()
|
||||||
|
raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
body, status_text = proc.stdout.rsplit("\n", 1)
|
||||||
|
status = int(status_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc
|
||||||
|
|
||||||
|
if status >= 400:
|
||||||
|
raise SystemExit(f"Gitea asset upload failed ({status}): {body}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body or "{}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_release(version: str, token: str) -> int:
|
||||||
|
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||||
|
tag = f"v{version}"
|
||||||
|
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
|
||||||
|
req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
release = json.loads(resp.read().decode("utf-8"))
|
||||||
|
release_id = release.get("id")
|
||||||
|
if not release_id:
|
||||||
|
raise SystemExit(f"Gitea release missing id: {release}")
|
||||||
|
print(f"Gitea release exists: {tag} (id {release_id})")
|
||||||
|
return int(release_id)
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code != 404:
|
||||||
detail = exc.read().decode("utf-8", "replace")
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
|
raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
def create_gitea_release(version: str, apk: Path) -> None:
|
|
||||||
token = GITEA_TOKEN.strip()
|
|
||||||
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
|
||||||
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
|
||||||
|
|
||||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
|
||||||
release = request_json(
|
release = request_json(
|
||||||
"POST",
|
"POST",
|
||||||
repo_path + "/releases",
|
repo_path + "/releases",
|
||||||
token,
|
token,
|
||||||
{
|
{
|
||||||
"tag_name": f"v{version}",
|
"tag_name": tag,
|
||||||
"target_commitish": BRANCH,
|
"target_commitish": BRANCH,
|
||||||
"name": f"v{version}",
|
"name": tag,
|
||||||
"body": f"I Want to Heal Android build v{version}",
|
"body": f"I Want to Heal Android build v{version}",
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
@@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None:
|
|||||||
release_id = release.get("id")
|
release_id = release.get("id")
|
||||||
if not release_id:
|
if not release_id:
|
||||||
raise SystemExit(f"Gitea release missing id: {release}")
|
raise SystemExit(f"Gitea release missing id: {release}")
|
||||||
|
print(f"Gitea release created: {tag} (id {release_id})")
|
||||||
|
return int(release_id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_gitea_release(version: str, apk: Path) -> None:
|
||||||
|
token = GITEA_TOKEN.strip()
|
||||||
|
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||||
|
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||||
|
|
||||||
|
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||||
|
release_id = find_or_create_release(version, token)
|
||||||
asset_name = urllib.parse.quote(apk.name)
|
asset_name = urllib.parse.quote(apk.name)
|
||||||
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
||||||
print(f"Gitea release uploaded: v{version}")
|
print(f"Gitea release uploaded: v{version}")
|
||||||
@@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
|
|||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
os.chdir(REPO_ROOT)
|
os.chdir(REPO_ROOT)
|
||||||
|
if args.release_only:
|
||||||
|
version = args.release_only.strip()
|
||||||
|
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
||||||
|
raise SystemExit("Version must look like 1.1.2")
|
||||||
|
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
|
||||||
|
if not apk.exists():
|
||||||
|
raise SystemExit(f"APK not found: {apk}")
|
||||||
|
create_gitea_release(version, apk)
|
||||||
|
return 0
|
||||||
|
|
||||||
version = prompt_version()
|
version = prompt_version()
|
||||||
apk = build_apk(version)
|
apk = build_apk(version)
|
||||||
commit_and_push(version)
|
commit_and_push(version)
|
||||||
|
|||||||
@@ -212,6 +212,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-arena-layout {
|
.iwt2-arena-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 172px minmax(0, 1fr);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
.iwt2-arena-stage {
|
.iwt2-arena-stage {
|
||||||
background: #090c10;
|
background: #090c10;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
grid-column: 2;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -317,6 +320,22 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bar em {
|
||||||
|
color: #fff7df;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
left: 0;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px 0 #050608;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-bar.boss span {
|
.iwt2-bar.boss span {
|
||||||
background: #dc5162;
|
background: #dc5162;
|
||||||
}
|
}
|
||||||
@@ -337,17 +356,18 @@
|
|||||||
.iwt2-party-list {
|
.iwt2-party-list {
|
||||||
background: rgba(15, 19, 26, 0.88);
|
background: rgba(15, 19, 26, 0.88);
|
||||||
border: 0;
|
border: 0;
|
||||||
left: 28px;
|
bottom: 0;
|
||||||
max-height: calc(100% - 112px);
|
left: 0;
|
||||||
|
max-height: none;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 28px;
|
top: 0;
|
||||||
width: 172px;
|
width: 172px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-content: start;
|
align-content: center;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,7 +1119,8 @@
|
|||||||
.iwt2-gear-class,
|
.iwt2-gear-class,
|
||||||
.iwt2-gear-slot,
|
.iwt2-gear-slot,
|
||||||
.iwt2-infusion-row,
|
.iwt2-infusion-row,
|
||||||
.iwt2-gear-upgrade-button {
|
.iwt2-gear-upgrade-button,
|
||||||
|
.iwt2-gear-back {
|
||||||
background: #151922;
|
background: #151922;
|
||||||
border: 2px solid #08090d;
|
border: 2px solid #08090d;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -1177,7 +1198,8 @@
|
|||||||
.iwt2-gear-class.game-selected,
|
.iwt2-gear-class.game-selected,
|
||||||
.iwt2-gear-slot.game-selected,
|
.iwt2-gear-slot.game-selected,
|
||||||
.iwt2-infusion-row.game-selected,
|
.iwt2-infusion-row.game-selected,
|
||||||
.iwt2-gear-upgrade-button.game-selected {
|
.iwt2-gear-upgrade-button.game-selected,
|
||||||
|
.iwt2-gear-back.game-selected {
|
||||||
outline-color: #e5b95f;
|
outline-color: #e5b95f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,6 +1209,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-back {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-upgrade-button:disabled,
|
.iwt2-gear-upgrade-button:disabled,
|
||||||
.iwt2-infusion-row:disabled {
|
.iwt2-infusion-row:disabled {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1219,6 +1247,28 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid span {
|
||||||
|
background: #0d1118;
|
||||||
|
border: 1px solid #29313d;
|
||||||
|
display: block;
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid strong {
|
||||||
|
color: #f2c96d;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid small {
|
||||||
|
color: #e5edf6;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-cost-list span.met {
|
.iwt2-gear-cost-list span.met {
|
||||||
color: #9ef0bd;
|
color: #9ef0bd;
|
||||||
}
|
}
|
||||||
@@ -1379,6 +1429,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) and (max-height: 620px) {
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.iwt2-arena-layout {
|
||||||
|
grid-template-columns: 154px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.game-shell.iwt2-shell {
|
.game-shell.iwt2-shell {
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
width: min(100%, calc(100% - 20px));
|
width: min(100%, calc(100% - 20px));
|
||||||
@@ -1618,11 +1672,29 @@
|
|||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-back {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-detail {
|
.iwt2-gear-detail {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid span {
|
||||||
|
padding: 5px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid strong,
|
||||||
|
.iwt2-gear-bonus-grid small {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-infusion-row {
|
.iwt2-infusion-row {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
grid-template-columns: 28px minmax(0, 1fr);
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
@@ -1700,20 +1772,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-list {
|
.iwt2-party-list {
|
||||||
gap: 5px;
|
align-content: center;
|
||||||
left: 10px;
|
gap: 7px;
|
||||||
max-height: calc(100% - 84px);
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
max-height: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
top: 10px;
|
top: 0;
|
||||||
width: 154px;
|
width: 154px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row {
|
.iwt2-party-row {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
grid-template-columns: 22px minmax(0, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
height: 62px;
|
height: 70px;
|
||||||
min-height: 62px;
|
min-height: 70px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,8 +1821,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row .iwt2-bar {
|
.iwt2-party-row .iwt2-bar {
|
||||||
height: 7px;
|
height: 12px;
|
||||||
margin-top: 2px;
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row .iwt2-bar.mana {
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-effects {
|
.iwt2-party-effects {
|
||||||
@@ -1768,6 +1846,7 @@
|
|||||||
|
|
||||||
.iwt2-ability-bar {
|
.iwt2-ability-bar {
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
display: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: repeat(6, 36px);
|
grid-template-columns: repeat(6, 36px);
|
||||||
max-width: 256px;
|
max-width: 256px;
|
||||||
@@ -1794,6 +1873,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 {
|
.combat-touch-lock-status {
|
||||||
background: #101216;
|
background: #101216;
|
||||||
border: 2px solid #d9b55a;
|
border: 2px solid #d9b55a;
|
||||||
|
|||||||
@@ -1,159 +1,29 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AuthScreen } from './components/AuthScreen'
|
import { AuthScreen } from './components/AuthScreen'
|
||||||
import IWantToHeal1App from './modes/iwt1/IWantToHeal1App'
|
|
||||||
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App'
|
||||||
import { useGameAction } from './input'
|
import type { Iwt2LocalSaveSlot, Iwt2Save } from './modes/iwt2/save/iwt2Repository'
|
||||||
import {
|
|
||||||
loadAuthSession,
|
|
||||||
type AuthSession,
|
|
||||||
} from './profile'
|
|
||||||
|
|
||||||
type GameVersion = 'iwt1' | 'iwt2'
|
type Iwt2Launch = {
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
const GAME_OPTIONS: Array<{
|
onlineBackupsAvailable: boolean
|
||||||
version: GameVersion
|
save: Iwt2Save
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(null)
|
const [launch, setLaunch] = useState<Iwt2Launch | null>(null)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
||||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null)
|
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
|
||||||
const [serverMessage, setServerMessage] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (launch) {
|
||||||
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) {
|
|
||||||
return (
|
|
||||||
<main className="game-shell">
|
|
||||||
<section className="message-panel">
|
|
||||||
<p className="eyebrow">Opening Chronicle</p>
|
|
||||||
<h1>Loading...</h1>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authSession) {
|
|
||||||
return (
|
|
||||||
<AuthScreen
|
|
||||||
onAuthenticated={acceptSession}
|
|
||||||
serverMessage={serverMessage}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedVersion === 'iwt1') {
|
|
||||||
return (
|
|
||||||
<IWantToHeal1App
|
|
||||||
initialSession={authSession}
|
|
||||||
onAuthenticationCleared={clearAuthSession}
|
|
||||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedVersion === 'iwt2') {
|
|
||||||
return (
|
return (
|
||||||
<IWantToHeal2App
|
<IWantToHeal2App
|
||||||
onlineBackupsAvailable={authSession.account?.id !== -1}
|
initialSave={launch.save}
|
||||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
localSlot={launch.localSlot}
|
||||||
|
onlineBackupsAvailable={launch.onlineBackupsAvailable}
|
||||||
|
onExitToSaveSelect={() => setLaunch(null)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <AuthScreen onContinue={setLaunch} />
|
||||||
<main className="game-shell game-version-shell">
|
|
||||||
<section className="game-version-screen" data-game-nav-active="true">
|
|
||||||
<div className="game-version-heading">
|
|
||||||
<p className="eyebrow">Select Game</p>
|
|
||||||
<h1>I Want To Heal</h1>
|
|
||||||
</div>
|
|
||||||
<div className="game-version-grid">
|
|
||||||
{GAME_OPTIONS.map((option, index) => (
|
|
||||||
<button
|
|
||||||
className={`game-version-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
|
||||||
data-controller-nav="skip"
|
|
||||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
|
||||||
key={option.version}
|
|
||||||
onClick={() => setSelectedVersion(option.version)}
|
|
||||||
onPointerDown={() => setSelectedIndex(index)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>{option.glyph}</span>
|
|
||||||
<div>
|
|
||||||
<strong>{option.title}</strong>
|
|
||||||
<small>{option.label}</small>
|
|
||||||
<p>{option.description}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -1,99 +1,381 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
loadAuthSession,
|
||||||
loginAccount,
|
loginAccount,
|
||||||
registerAccount,
|
logoutAccount,
|
||||||
type AuthSession,
|
type Account,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { selectOfflineMode, selectOnlineMode } from '../gameRepository'
|
||||||
import {
|
import {
|
||||||
createOfflineCharacter,
|
createDefaultIwt2Save,
|
||||||
hasOfflineCharacter,
|
IWT2_LOCAL_SAVE_SLOTS,
|
||||||
resumeOfflineCharacter,
|
loadIwt2OnlineSave,
|
||||||
selectOnlineMode,
|
loadIwt2SaveSlots,
|
||||||
} from '../gameRepository'
|
replaceIwt2Save,
|
||||||
|
selectActiveIwt2SaveSlot,
|
||||||
|
type Iwt2LocalSaveSlot,
|
||||||
|
type Iwt2LocalSaveSlots,
|
||||||
|
type Iwt2Save,
|
||||||
|
} from '../modes/iwt2/save/iwt2Repository'
|
||||||
|
|
||||||
|
type Iwt2Launch = {
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
|
onlineBackupsAvailable: boolean
|
||||||
|
save: Iwt2Save
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onAuthenticated: (session: AuthSession) => void
|
onContinue: (launch: Iwt2Launch) => void
|
||||||
serverMessage?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
const IWT2_CHARACTER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 -]{1,17}$/
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
|
||||||
|
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 [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [characterName, setCharacterName] = useState('')
|
const [checkingSession, setCheckingSession] = useState(true)
|
||||||
const [offlineName, setOfflineName] = useState('')
|
const [checkingOnlineSave, setCheckingOnlineSave] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('Choose a slot or sign in to inspect the online save.')
|
||||||
const offlineCharacterExists = hasOfflineCharacter()
|
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()
|
event.preventDefault()
|
||||||
setBusy(true)
|
const name = characterName.trim()
|
||||||
setMessage('')
|
if (!IWT2_CHARACTER_NAME_PATTERN.test(name)) {
|
||||||
|
setMessage('Character name must be 2–18 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 {
|
try {
|
||||||
selectOnlineMode()
|
selectOnlineMode()
|
||||||
const session = mode === 'login'
|
const session = await loginAccount(username, password)
|
||||||
? await loginAccount(username, password)
|
if (!session.account) throw new Error('Account session was not returned.')
|
||||||
: await registerAccount(username, password, characterName)
|
setAccount(session.account)
|
||||||
onAuthenticated(session)
|
await inspectOnlineSave()
|
||||||
} catch (reason) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginOffline() {
|
function continueOnline() {
|
||||||
setMessage('')
|
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 {
|
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) {
|
} 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 onlineBusy = checkingSession || checkingOnlineSave || submitting
|
||||||
const session = resumeOfflineCharacter()
|
|
||||||
if (session) onAuthenticated(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell save-gateway-shell">
|
||||||
<section className="auth-panel">
|
<section className="save-gateway-panel">
|
||||||
<div className="auth-brand">
|
<header className="save-gateway-heading">
|
||||||
<p className="eyebrow">Healer RPG</p>
|
<div>
|
||||||
<h1>I want to Heal</h1>
|
<p className="eyebrow">I Want To Heal 2</p>
|
||||||
<p>
|
<h1>Choose Character</h1>
|
||||||
Build your healer, master each dungeon, and compete for the most
|
</div>
|
||||||
efficient clears.
|
<p>Three local slots. Each character keeps separate progress, timestamp, and XP.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="save-option-grid">
|
||||||
|
{IWT2_LOCAL_SAVE_SLOTS.map((slot) => {
|
||||||
|
const save = localSaves[slot]
|
||||||
|
const editing = editingSlot === slot
|
||||||
|
return (
|
||||||
|
<article className={`save-option-card local-save-card ${save ? '' : 'empty'}`} key={slot}>
|
||||||
|
<div className="save-option-heading">
|
||||||
|
<span aria-hidden="true">{slot}</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Local Save</p>
|
||||||
|
<h2>Slot {slot}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<form className="character-create-form" onSubmit={(event) => createCharacter(event, slot)}>
|
||||||
|
<label>
|
||||||
|
Character Name
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
maxLength={18}
|
||||||
|
minLength={2}
|
||||||
|
onChange={(event) => setCharacterName(event.target.value)}
|
||||||
|
pattern="[A-Za-z][A-Za-z0-9 \-]{1,17}"
|
||||||
|
placeholder="Mira"
|
||||||
|
required
|
||||||
|
value={characterName}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
{save ? `Replaces ${save.character.name} in this slot.` : 'Creates a fresh Level 1 character.'}
|
||||||
|
</small>
|
||||||
|
<div className="slot-save-actions">
|
||||||
|
<button
|
||||||
|
className={`save-continue-button ${save ? 'danger' : ''}`}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{save ? `Replace Slot ${slot}` : `Create in Slot ${slot}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="save-secondary-button"
|
||||||
|
onClick={() => setEditingSlot(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : save ? (
|
||||||
|
<>
|
||||||
|
<strong className="save-character-name">{save.character.name}</strong>
|
||||||
|
<SaveMetadata save={save} />
|
||||||
|
<div className="slot-save-actions">
|
||||||
|
<button
|
||||||
|
aria-label={`Continue ${save.character.name} in Slot ${slot}`}
|
||||||
|
className="save-continue-button"
|
||||||
|
onClick={() => continueLocal(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Continue Offline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Create new character in Slot ${slot}`}
|
||||||
|
className="save-secondary-button"
|
||||||
|
onClick={() => openCharacterEditor(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
New Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="save-empty-state">Empty slot</p>
|
||||||
|
<button
|
||||||
|
aria-label={`Create character in Slot ${slot}`}
|
||||||
|
className="save-continue-button"
|
||||||
|
onClick={() => openCharacterEditor(slot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Create Character
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="online-save-panel">
|
||||||
|
<div className="save-option-heading online-save-heading">
|
||||||
|
<span aria-hidden="true">O</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Account</p>
|
||||||
|
<h2>Online Save</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{account ? (
|
||||||
|
<>
|
||||||
|
<div className="online-save-summary">
|
||||||
|
<strong className="save-character-name">
|
||||||
|
{onlineSave?.character.name ?? account.username}
|
||||||
|
</strong>
|
||||||
|
{onlineSave ? (
|
||||||
|
<SaveMetadata save={onlineSave} />
|
||||||
|
) : (
|
||||||
|
<p className="save-empty-state">
|
||||||
|
{onlineBusy ? 'Checking online save...' : 'No online IWT2 save found.'}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="online-save-destination">
|
||||||
<div className="auth-card">
|
Copy Into
|
||||||
<div className="auth-tabs">
|
<select
|
||||||
<button
|
disabled={onlineBusy || !onlineSave}
|
||||||
className={mode === 'login' ? 'selected' : ''}
|
onChange={(event) => {
|
||||||
onClick={() => {
|
setOnlineDestinationSlot(Number(event.target.value) as Iwt2LocalSaveSlot)
|
||||||
setMode('login')
|
setConfirmOnlineDestination(null)
|
||||||
setMessage('')
|
|
||||||
}}
|
}}
|
||||||
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
Sign In
|
{confirmOnlineDestination === onlineDestinationSlot
|
||||||
|
? `Confirm Replace Slot ${onlineDestinationSlot}`
|
||||||
|
: `Load Into Slot ${onlineDestinationSlot}`}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={mode === 'register' ? 'selected' : ''}
|
className="save-secondary-button"
|
||||||
onClick={() => {
|
disabled={onlineBusy}
|
||||||
setMode('register')
|
onClick={() => { void handleDifferentAccount() }}
|
||||||
setMessage('')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Create Account
|
Switch Account
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<form onSubmit={submit}>
|
) : (
|
||||||
|
<form className="online-save-login" onSubmit={signIn}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input
|
<input
|
||||||
@@ -106,23 +388,10 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
|||||||
value={username}
|
value={username}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{mode === 'register' && (
|
|
||||||
<label>
|
|
||||||
Character Name
|
|
||||||
<input
|
|
||||||
autoComplete="nickname"
|
|
||||||
maxLength={20}
|
|
||||||
minLength={2}
|
|
||||||
onChange={(event) => setCharacterName(event.target.value)}
|
|
||||||
required
|
|
||||||
value={characterName}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input
|
<input
|
||||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
autoComplete="current-password"
|
||||||
maxLength={128}
|
maxLength={128}
|
||||||
minLength={10}
|
minLength={10}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
@@ -131,62 +400,19 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
|||||||
value={password}
|
value={password}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" disabled={busy} type="submit">
|
<button className="save-continue-button" disabled={onlineBusy} type="submit">
|
||||||
{busy
|
{onlineBusy ? 'Checking Account...' : 'Sign In & Check Save'}
|
||||||
? 'Working...'
|
|
||||||
: mode === 'login'
|
|
||||||
? 'Enter Chronicle'
|
|
||||||
: 'Begin Adventure'}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className={`auth-message ${message ? 'error' : ''}`}>
|
|
||||||
{message || serverMessage || (
|
|
||||||
mode === 'register'
|
|
||||||
? 'The first account keeps the current local character and save.'
|
|
||||||
: 'Sign in to continue your character.'
|
|
||||||
)}
|
)}
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="offline-divider"><span>or</span></div>
|
|
||||||
|
|
||||||
<section className="offline-entry">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Local Save</p>
|
|
||||||
<h2>Play Offline</h2>
|
|
||||||
<p>
|
|
||||||
No account or connection required. Offline progress stays on
|
|
||||||
this device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{offlineCharacterExists && (
|
|
||||||
<button
|
|
||||||
className="offline-resume-button"
|
|
||||||
onClick={resumeOffline}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Continue Offline Character
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<label>
|
|
||||||
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
|
|
||||||
<input
|
|
||||||
maxLength={20}
|
|
||||||
minLength={2}
|
|
||||||
onChange={(event) => setOfflineName(event.target.value)}
|
|
||||||
placeholder="Mira"
|
|
||||||
value={offlineName}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="text-button offline-new-button"
|
|
||||||
onClick={beginOffline}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
<p
|
||||||
|
aria-live="polite"
|
||||||
|
className={`save-gateway-status ${messageIsError ? 'error' : ''}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export function CombatScreen({
|
|||||||
roguelikeAbilityLabelMode = 'ability',
|
roguelikeAbilityLabelMode = 'ability',
|
||||||
roguelikeEncounterPool,
|
roguelikeEncounterPool,
|
||||||
onExit,
|
onExit,
|
||||||
|
onMainMenu = onExit,
|
||||||
onProfileUpdated,
|
onProfileUpdated,
|
||||||
}: {
|
}: {
|
||||||
difficulty: Difficulty
|
difficulty: Difficulty
|
||||||
@@ -279,6 +280,7 @@ export function CombatScreen({
|
|||||||
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
||||||
roguelikeEncounterPool?: DungeonEncounter[]
|
roguelikeEncounterPool?: DungeonEncounter[]
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onMainMenu?: () => void
|
||||||
onProfileUpdated: (profile: CharacterProfile) => void
|
onProfileUpdated: (profile: CharacterProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const staticEncounters = useMemo(
|
const staticEncounters = useMemo(
|
||||||
@@ -1399,10 +1401,10 @@ export function CombatScreen({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Game Paused</p>
|
<p className="eyebrow">Game Paused</p>
|
||||||
<h2>{dungeon.name}</h2>
|
<h2>{dungeon.name}</h2>
|
||||||
<p>Combat is stopped. Resume the fight or leave the current run.</p>
|
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
|
||||||
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
<button onClick={() => setPaused(false)} type="button">Continue</button>
|
||||||
<button className="secondary-result-button" onClick={onExit} type="button">
|
<button className="secondary-result-button" onClick={onMainMenu} type="button">
|
||||||
Leave {contentName}
|
Main Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export function PvPRoguelikeScreen({
|
|||||||
contentType,
|
contentType,
|
||||||
encounterPool,
|
encounterPool,
|
||||||
onExit,
|
onExit,
|
||||||
|
onMainMenu = onExit,
|
||||||
onProfileUpdated,
|
onProfileUpdated,
|
||||||
}: {
|
}: {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
@@ -301,6 +302,7 @@ export function PvPRoguelikeScreen({
|
|||||||
contentType: PvpContentType
|
contentType: PvpContentType
|
||||||
encounterPool: DungeonEncounter[]
|
encounterPool: DungeonEncounter[]
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onMainMenu?: () => void
|
||||||
onProfileUpdated: (profile: CharacterProfile) => void
|
onProfileUpdated: (profile: CharacterProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||||
@@ -1423,14 +1425,14 @@ export function PvPRoguelikeScreen({
|
|||||||
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
||||||
if (entry.kind === 'queueBack') onExit()
|
if (entry.kind === 'queueBack') onExit()
|
||||||
else if (entry.kind === 'pauseResume') setPaused(false)
|
else if (entry.kind === 'pauseResume') setPaused(false)
|
||||||
else if (entry.kind === 'pauseLeave') onExit()
|
else if (entry.kind === 'pauseLeave') onMainMenu()
|
||||||
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
||||||
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
||||||
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
||||||
}
|
}
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
if (status === 'queueing' || status === 'round-countdown') {
|
if (status === 'queueing') {
|
||||||
if (action === 'back' || action === 'pause') onExit()
|
if (action === 'back' || action === 'pause') onExit()
|
||||||
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
return
|
return
|
||||||
@@ -1447,6 +1449,13 @@ export function PvPRoguelikeScreen({
|
|||||||
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (status === 'round-countdown') {
|
||||||
|
if (action === 'back' || action === 'pause') {
|
||||||
|
setOverlaySelectedIndex(0)
|
||||||
|
setPaused(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (status === 'upgrade-choice') {
|
if (status === 'upgrade-choice') {
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
openPvpOverlayEntry(activePvpOverlayEntry())
|
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
@@ -1780,16 +1789,16 @@ export function PvPRoguelikeScreen({
|
|||||||
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Resume
|
Continue
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
onClick={onExit}
|
onClick={onMainMenu}
|
||||||
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Leave
|
Main Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1841,6 +1841,10 @@ export function selectOnlineMode() {
|
|||||||
writeMode('online')
|
writeMode('online')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectOfflineMode() {
|
||||||
|
writeMode('offline-local')
|
||||||
|
}
|
||||||
|
|
||||||
export function createOfflineCharacter(characterName: string): AuthSession {
|
export function createOfflineCharacter(characterName: string): AuthSession {
|
||||||
const name = characterName.trim() || 'Mira'
|
const name = characterName.trim() || 'Mira'
|
||||||
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
||||||
|
|||||||
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
|
|||||||
).some(isVisible)
|
).some(isVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDedicatedNavigationAction(action: InputAction) {
|
||||||
|
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
||||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||||
detail: { action, device },
|
detail: { action, device },
|
||||||
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
setLastDevice(device)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
if (
|
||||||
|
device === 'controller'
|
||||||
|
&& dedicatedNavAction
|
||||||
|
&& !keyboardInputRef.current
|
||||||
|
&& hasDedicatedGameNavigation()
|
||||||
|
) {
|
||||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
dispatchGameAction(action, device)
|
dispatchGameAction(action, device)
|
||||||
return
|
return
|
||||||
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'navigateRight',
|
'navigateRight',
|
||||||
'confirm',
|
'confirm',
|
||||||
'back',
|
'back',
|
||||||
|
'pause',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const directTargetActions = [
|
const directTargetActions = [
|
||||||
'targetParty1',
|
'targetParty1',
|
||||||
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'navigateLeft',
|
'navigateLeft',
|
||||||
'navigateRight',
|
'navigateRight',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
|
const dedicatedNavigationActive = hasDedicatedGameNavigation() && (!combatActive || uiOverlay)
|
||||||
|
const action = dedicatedNavigationActive
|
||||||
|
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||||
|
?? (DPAD_NAV_ACTIONS[token] && isDedicatedNavigationAction(DPAD_NAV_ACTIONS[token])
|
||||||
|
? DPAD_NAV_ACTIONS[token]
|
||||||
|
: undefined)
|
||||||
|
: DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
|
||||||
? DPAD_NAV_ACTIONS[token]
|
? DPAD_NAV_ACTIONS[token]
|
||||||
: uiOverlay
|
: uiOverlay
|
||||||
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||||
@@ -1027,7 +1043,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
<div className="controller-keyboard-heading">
|
<div className="controller-keyboard-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Controller Keyboard</p>
|
<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>
|
</div>
|
||||||
<button onClick={closeKeyboard} type="button">Done</button>
|
<button onClick={closeKeyboard} type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -860,6 +860,7 @@ function IWantToHeal1App({
|
|||||||
onExit={() => {
|
onExit={() => {
|
||||||
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
||||||
}}
|
}}
|
||||||
|
onMainMenu={() => setScreen('menu')}
|
||||||
onProfileUpdated={setProfile}
|
onProfileUpdated={setProfile}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -893,6 +894,10 @@ function IWantToHeal1App({
|
|||||||
setRoguelikeVariant('pvp')
|
setRoguelikeVariant('pvp')
|
||||||
setScreen('roguelike')
|
setScreen('roguelike')
|
||||||
}}
|
}}
|
||||||
|
onMainMenu={() => {
|
||||||
|
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||||
|
setScreen('menu')
|
||||||
|
}}
|
||||||
onProfileUpdated={setProfile}
|
onProfileUpdated={setProfile}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ import {
|
|||||||
Iwt2RoguelikeUpgradeScreen,
|
Iwt2RoguelikeUpgradeScreen,
|
||||||
Iwt2SettingsScreen,
|
Iwt2SettingsScreen,
|
||||||
} from './screens/Iwt2ShellScreens'
|
} from './screens/Iwt2ShellScreens'
|
||||||
import {
|
import { writeIwt2Save, type Iwt2LocalSaveSlot, type Iwt2Save } from './save/iwt2Repository'
|
||||||
loadIwt2Save,
|
|
||||||
writeIwt2Save,
|
|
||||||
type Iwt2Save,
|
|
||||||
} from './save/iwt2Repository'
|
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||||
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +42,18 @@ import {
|
|||||||
type Iwt2RoguelikeSelfBuffId,
|
type Iwt2RoguelikeSelfBuffId,
|
||||||
type Iwt2RoguelikeVariant,
|
type Iwt2RoguelikeVariant,
|
||||||
} from './content/roguelike'
|
} from './content/roguelike'
|
||||||
|
import {
|
||||||
|
createIwt2WeightedRoguelikeBossPair,
|
||||||
|
createUniformIwt2RoguelikeBossPair,
|
||||||
|
enabledIwt2RoguelikeBossPool,
|
||||||
|
IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED,
|
||||||
|
IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS,
|
||||||
|
IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED,
|
||||||
|
IWT2_PVP_STADIUM_ENABLED_BOSS_IDS,
|
||||||
|
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
} from './content/roguelikeBossProgression'
|
||||||
|
|
||||||
type Iwt2Screen =
|
type Iwt2Screen =
|
||||||
| 'menu'
|
| 'menu'
|
||||||
@@ -64,9 +72,11 @@ type Iwt2Screen =
|
|||||||
const IWT2_MENU_COLUMNS = 2
|
const IWT2_MENU_COLUMNS = 2
|
||||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||||
|
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
|
||||||
|
|
||||||
type Iwt2RoguelikeRunState = {
|
type Iwt2RoguelikeRunState = {
|
||||||
bossIds: Iwt2BossId[]
|
bossIds: Iwt2BossId[]
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
@@ -126,8 +136,8 @@ const MENU_ITEMS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
screen: 'cloud-save',
|
screen: 'cloud-save',
|
||||||
title: 'Backup Slot',
|
title: 'Cloud Backup',
|
||||||
description: 'Choose local, online, or fresh IWT2 progress.',
|
description: 'Compare the active local slot with your online backup.',
|
||||||
glyph: 'C',
|
glyph: 'C',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,15 +149,19 @@ const MENU_ITEMS: Array<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function IWantToHeal2App({
|
export function IWantToHeal2App({
|
||||||
|
initialSave,
|
||||||
|
localSlot,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onBackToGameSelect,
|
onExitToSaveSelect,
|
||||||
}: {
|
}: {
|
||||||
|
initialSave: Iwt2Save
|
||||||
|
localSlot: Iwt2LocalSaveSlot
|
||||||
onlineBackupsAvailable: boolean
|
onlineBackupsAvailable: boolean
|
||||||
onBackToGameSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
const [save, setSave] = useState<Iwt2Save>(initialSave)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||||
@@ -162,8 +176,8 @@ export function IWantToHeal2App({
|
|||||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeIwt2Save(save)
|
writeIwt2Save(save, localSlot)
|
||||||
}, [save])
|
}, [localSlot, save])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancelPvpQueueRef.current?.()
|
cancelPvpQueueRef.current?.()
|
||||||
@@ -191,7 +205,7 @@ export function IWantToHeal2App({
|
|||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (screen !== 'menu' || device !== 'controller') return
|
if (screen !== 'menu' || device !== 'controller') return
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
onBackToGameSelect()
|
onExitToSaveSelect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
@@ -244,10 +258,13 @@ export function IWantToHeal2App({
|
|||||||
buffs: roguelikeRun.buffs,
|
buffs: roguelikeRun.buffs,
|
||||||
contentType: roguelikeRun.contentType,
|
contentType: roguelikeRun.contentType,
|
||||||
debuffs: roguelikeRun.debuffs,
|
debuffs: roguelikeRun.debuffs,
|
||||||
|
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
|
||||||
|
bossesDefeated: roguelikeRun.bossesDefeated,
|
||||||
onVictory: () => {
|
onVictory: () => {
|
||||||
setRoguelikeRun((current) => current
|
setRoguelikeRun((current) => current
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
|
bossesDefeated: current.bossesDefeated + current.bossIds.length,
|
||||||
...buildRoguelikeChoices(save, current.variant),
|
...buildRoguelikeChoices(save, current.variant),
|
||||||
}
|
}
|
||||||
: current)
|
: current)
|
||||||
@@ -258,6 +275,7 @@ export function IWantToHeal2App({
|
|||||||
}}
|
}}
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('roguelike')}
|
onBack={() => setScreen('roguelike')}
|
||||||
|
onMainMenu={() => setScreen('menu')}
|
||||||
onPvpRequeue={() => {
|
onPvpRequeue={() => {
|
||||||
setScreen('roguelike')
|
setScreen('roguelike')
|
||||||
startIwt2RoguelikeRun()
|
startIwt2RoguelikeRun()
|
||||||
@@ -270,7 +288,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
if (screen === 'roguelike-upgrade' && roguelikeRun) {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2RoguelikeUpgradeScreen
|
<Iwt2RoguelikeUpgradeScreen
|
||||||
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
activeBuffSummary={summarizeIwt2Buffs(save, roguelikeRun.buffs)}
|
||||||
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
activeDebuffSummary={summarizeIwt2Debuffs(save, roguelikeRun.debuffs)}
|
||||||
@@ -294,7 +312,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'dungeons') {
|
if (screen === 'dungeons') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2DungeonsScreen
|
<Iwt2DungeonsScreen
|
||||||
difficultySlug={selectedDungeonDifficultySlug}
|
difficultySlug={selectedDungeonDifficultySlug}
|
||||||
save={save}
|
save={save}
|
||||||
@@ -315,7 +333,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'roguelike') {
|
if (screen === 'roguelike') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2RoguelikeScreen
|
<Iwt2RoguelikeScreen
|
||||||
contentType={roguelikeContentType}
|
contentType={roguelikeContentType}
|
||||||
variant={roguelikeVariant}
|
variant={roguelikeVariant}
|
||||||
@@ -345,7 +363,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'raids') {
|
if (screen === 'raids') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2ModeScreen
|
<Iwt2ModeScreen
|
||||||
difficultySlug={selectedRaidDifficultySlug}
|
difficultySlug={selectedRaidDifficultySlug}
|
||||||
mode="Raids"
|
mode="Raids"
|
||||||
@@ -379,7 +397,7 @@ export function IWantToHeal2App({
|
|||||||
save={save}
|
save={save}
|
||||||
title="Gear Upgrade"
|
title="Gear Upgrade"
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onBackToGameSelect={onBackToGameSelect}
|
onExitToSaveSelect={onExitToSaveSelect}
|
||||||
/>
|
/>
|
||||||
<Iwt2GearUpgradeScreen
|
<Iwt2GearUpgradeScreen
|
||||||
save={save}
|
save={save}
|
||||||
@@ -393,7 +411,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'customize-character') {
|
if (screen === 'customize-character') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2CustomizeCharacterScreen
|
<Iwt2CustomizeCharacterScreen
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
@@ -406,7 +424,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'cloud-save') {
|
if (screen === 'cloud-save') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2CloudSaveScreen
|
<Iwt2CloudSaveScreen
|
||||||
save={save}
|
save={save}
|
||||||
onlineBackupsAvailable={onlineBackupsAvailable}
|
onlineBackupsAvailable={onlineBackupsAvailable}
|
||||||
@@ -420,7 +438,7 @@ export function IWantToHeal2App({
|
|||||||
if (screen === 'settings') {
|
if (screen === 'settings') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
<Iwt2SettingsScreen onBack={() => setScreen('menu')} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -428,7 +446,7 @@ export function IWantToHeal2App({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onExitToSaveSelect={onExitToSaveSelect} />
|
||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||||
@@ -521,7 +539,8 @@ function createRoguelikeRun(
|
|||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
): Iwt2RoguelikeRunState {
|
): Iwt2RoguelikeRunState {
|
||||||
return {
|
return {
|
||||||
bossIds: createRandomRoguelikeBossPair(),
|
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
|
||||||
|
bossesDefeated: 0,
|
||||||
buffs: [],
|
buffs: [],
|
||||||
contentType,
|
contentType,
|
||||||
debuffs: [],
|
debuffs: [],
|
||||||
@@ -589,15 +608,45 @@ function applyRoguelikeChoice(
|
|||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
...nextBase,
|
...nextBase,
|
||||||
bossIds: createRandomRoguelikeBossPair(),
|
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
|
||||||
...buildRoguelikeChoices(save, run.variant),
|
...buildRoguelikeChoices(save, run.variant),
|
||||||
stage: run.stage + 1,
|
stage: run.stage + 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
function createRoguelikeBossPair(
|
||||||
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
variant: Iwt2RoguelikeVariant,
|
||||||
return chooseRunChoices(pool, 2)
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
stage: number,
|
||||||
|
bossesDefeated: number,
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const weightedProgressionEnabled = variant === 'pve'
|
||||||
|
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
: contentType === 'stadium'
|
||||||
|
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
||||||
|
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
|
||||||
|
|
||||||
|
if (weightedProgressionEnabled) {
|
||||||
|
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
|
||||||
|
}
|
||||||
|
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count })
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikeBossPoolFor(
|
||||||
|
variant: Iwt2RoguelikeVariant,
|
||||||
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
): Iwt2BossId[] | undefined {
|
||||||
|
if (variant !== 'pvp') return undefined
|
||||||
|
if (contentType === 'stadium') {
|
||||||
|
return IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED
|
||||||
|
? enabledIwt2RoguelikeBossPool(IWT2_PVP_STADIUM_ENABLED_BOSS_IDS)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
return IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED
|
||||||
|
? enabledIwt2RoguelikeBossPool(IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||||
@@ -643,19 +692,19 @@ function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>)
|
|||||||
|
|
||||||
function Iwt2Header({
|
function Iwt2Header({
|
||||||
onBack,
|
onBack,
|
||||||
onBackToGameSelect,
|
onExitToSaveSelect,
|
||||||
save,
|
save,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
onBackToGameSelect: () => void
|
onExitToSaveSelect: () => void
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
title?: string
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
<button className="brand-button" onClick={onExitToSaveSelect} type="button">
|
||||||
<strong>Games</strong>
|
<strong>Saves</strong>
|
||||||
</button>
|
</button>
|
||||||
{title && <strong className="iwt2-header-title">{title}</strong>}
|
{title && <strong className="iwt2-header-title">{title}</strong>}
|
||||||
<div className="character-summary">
|
<div className="character-summary">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
export function ArenaBar({
|
export function ArenaBar({
|
||||||
className = '',
|
className = '',
|
||||||
current,
|
current,
|
||||||
|
label,
|
||||||
max,
|
max,
|
||||||
shield = 0,
|
shield = 0,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
current: number
|
current: number
|
||||||
|
label?: string
|
||||||
max: number
|
max: number
|
||||||
shield?: number
|
shield?: number
|
||||||
}) {
|
}) {
|
||||||
@@ -15,6 +17,7 @@ export function ArenaBar({
|
|||||||
<div className={`iwt2-bar ${className}`}>
|
<div className={`iwt2-bar ${className}`}>
|
||||||
<span style={{ width: `${percent}%` }} />
|
<span style={{ width: `${percent}%` }} />
|
||||||
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
||||||
|
{label && <em>{label}</em>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ export function PartyFrames({
|
|||||||
<div>
|
<div>
|
||||||
<div className="iwt2-party-row-title">
|
<div className="iwt2-party-row-title">
|
||||||
<strong>{meta.name}</strong>
|
<strong>{meta.name}</strong>
|
||||||
<small>{Math.ceil(member.health)}</small>
|
|
||||||
</div>
|
</div>
|
||||||
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
|
<ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
|
||||||
{member.maxMana > 0 && (
|
{member.maxMana > 0 && (
|
||||||
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
spriteYOffsetScale: -0.1,
|
spriteYOffsetScale: -0.1,
|
||||||
color: '#d66a35',
|
color: '#d66a35',
|
||||||
accentColor: '#ffd166',
|
accentColor: '#ffd166',
|
||||||
maxHealth: 400,
|
maxHealth: 750,
|
||||||
radius: 29,
|
radius: 29,
|
||||||
moveSpeed: 118,
|
moveSpeed: 118,
|
||||||
meleeRange: 56,
|
meleeRange: 56,
|
||||||
@@ -220,11 +220,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
firePuddleRadius: 42,
|
firePuddleRadius: 42,
|
||||||
firePuddleDamage: 15,
|
firePuddleDamage: 15,
|
||||||
firePuddleSeconds: 8,
|
firePuddleSeconds: 8,
|
||||||
birdWaveThresholds: [0.9, 0.4],
|
birdWaveThresholds: [0.5],
|
||||||
birdFlightCooldown: 10,
|
birdFlightCooldown: 10,
|
||||||
birdFlightWindup: 0.85,
|
birdFlightWindup: 0.85,
|
||||||
birdFlightSpeed: 360,
|
birdFlightSpeed: 360,
|
||||||
birdHealth: 58,
|
birdHealth: 55,
|
||||||
birdRadius: 16,
|
birdRadius: 16,
|
||||||
birdContactDamage: 26,
|
birdContactDamage: 26,
|
||||||
birdStunSeconds: 0.75,
|
birdStunSeconds: 0.75,
|
||||||
|
|||||||
@@ -2,12 +2,47 @@ export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogu
|
|||||||
|
|
||||||
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
|
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
|
||||||
|
|
||||||
|
export type Iwt2ClassEquipmentSlot =
|
||||||
|
| 'weapon'
|
||||||
|
| 'offhand'
|
||||||
|
| 'head'
|
||||||
|
| 'chest'
|
||||||
|
| 'hands'
|
||||||
|
| 'legs'
|
||||||
|
|
||||||
|
export type Iwt2ClassLayerMotion = {
|
||||||
|
offsetXScale?: number
|
||||||
|
offsetYScale?: number
|
||||||
|
rotation?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2ClassWeaponLayer = {
|
||||||
|
id: string
|
||||||
|
slot: Iwt2ClassEquipmentSlot
|
||||||
|
url: string
|
||||||
|
drawOrder: 'behindBody' | 'front'
|
||||||
|
heightScale: number
|
||||||
|
offsetXScale: number
|
||||||
|
offsetYScale: number
|
||||||
|
originX?: number
|
||||||
|
originY?: number
|
||||||
|
attackMotion?: Iwt2ClassLayerMotion
|
||||||
|
tierAccentEligible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2ClassLayeredArenaSprite = {
|
||||||
|
bodyUrl: string
|
||||||
|
bodyHeightScale?: number
|
||||||
|
weaponLayers: Iwt2ClassWeaponLayer[]
|
||||||
|
}
|
||||||
|
|
||||||
export type Iwt2ClassMetadata = {
|
export type Iwt2ClassMetadata = {
|
||||||
id: Iwt2PlayerClassId
|
id: Iwt2PlayerClassId
|
||||||
name: string
|
name: string
|
||||||
role: Iwt2PlayerClassRole
|
role: Iwt2PlayerClassRole
|
||||||
icon: string
|
icon: string
|
||||||
arenaSpriteUrl: string
|
arenaSpriteUrl: string
|
||||||
|
arenaLayeredSprite?: Iwt2ClassLayeredArenaSprite
|
||||||
uiIconUrl: string
|
uiIconUrl: string
|
||||||
color: string
|
color: string
|
||||||
accentColor: string
|
accentColor: string
|
||||||
@@ -118,6 +153,28 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '⚔',
|
icon: '⚔',
|
||||||
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
|
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
|
||||||
|
arenaLayeredSprite: {
|
||||||
|
bodyUrl: '/iwt2/classes/arena/layers/warrior-body.png',
|
||||||
|
weaponLayers: [
|
||||||
|
{
|
||||||
|
id: 'axe',
|
||||||
|
slot: 'weapon',
|
||||||
|
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
|
||||||
|
drawOrder: 'front',
|
||||||
|
heightScale: 1.72,
|
||||||
|
offsetXScale: 0.38,
|
||||||
|
offsetYScale: -0.88,
|
||||||
|
originX: 0.18,
|
||||||
|
originY: 0.5,
|
||||||
|
attackMotion: {
|
||||||
|
offsetXScale: 0.22,
|
||||||
|
offsetYScale: -0.18,
|
||||||
|
rotation: 0.54,
|
||||||
|
},
|
||||||
|
tierAccentEligible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
|
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
|
||||||
color: '#ff7675',
|
color: '#ff7675',
|
||||||
accentColor: '#ffd0d0',
|
accentColor: '#ffd0d0',
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
|
|||||||
import type { Iwt2PlayerClassId } from './classes'
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
import { iwt2BossCoinRewardFor } from './bossRewards'
|
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||||
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
|
||||||
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||||
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
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 =
|
export type Iwt2GearStatId =
|
||||||
| 'maxHealth'
|
| 'maxHealth'
|
||||||
| 'moveSpeed'
|
| 'moveSpeed'
|
||||||
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
|
|||||||
export type Iwt2ClassGearProgress = {
|
export type Iwt2ClassGearProgress = {
|
||||||
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||||
infusionAbilityId: Iwt2InfusionAbilityId | null
|
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
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_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> = {
|
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
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(
|
export function iwt2GearUpgradeCosts(
|
||||||
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
|
|||||||
slotId: Iwt2GearSlotId,
|
slotId: Iwt2GearSlotId,
|
||||||
currentLevel: Iwt2GearLevel,
|
currentLevel: Iwt2GearLevel,
|
||||||
): Iwt2GearUpgradeCost[] {
|
): Iwt2GearUpgradeCost[] {
|
||||||
if (currentLevel >= 5) return []
|
if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
|
||||||
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||||
const slug = upgradeDifficultySlug(nextLevel)
|
const slug = upgradeDifficultySlug(nextLevel)
|
||||||
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
|
|||||||
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||||
]
|
]
|
||||||
return [
|
if (nextLevel === 5) return [
|
||||||
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
{ 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(
|
export function iwt2InfusionCosts(
|
||||||
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
|||||||
feet: { level: 0 },
|
feet: { level: 0 },
|
||||||
},
|
},
|
||||||
infusionAbilityId: null,
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +211,7 @@ function slotRecipe(
|
|||||||
|
|
||||||
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||||
if (level <= 2) return 'initiate'
|
if (level <= 2) return 'initiate'
|
||||||
|
if (level >= 6) return 'veteran'
|
||||||
if (level === 3) return 'veteran'
|
if (level === 3) return 'veteran'
|
||||||
if (level === 4) return 'champion'
|
if (level === 4) return 'champion'
|
||||||
return 'mythic'
|
return 'mythic'
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { IWT2_PARTY_ORDER } from './classes'
|
||||||
|
import {
|
||||||
|
IWT2_GEAR_SLOTS,
|
||||||
|
type Iwt2GearLevel,
|
||||||
|
type Iwt2GearProgress,
|
||||||
|
} from './gear'
|
||||||
|
|
||||||
|
export type Iwt2PvpGearNormalizationConfig = {
|
||||||
|
enabled: boolean
|
||||||
|
gearLevel: Iwt2GearLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_PVP_NORMALIZED_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||||
|
|
||||||
|
export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = {
|
||||||
|
enabled: true,
|
||||||
|
gearLevel: IWT2_PVP_NORMALIZED_GEAR_LEVEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2PvpNormalizedGearProgress(
|
||||||
|
config = IWT2_PVP_GEAR_NORMALIZATION,
|
||||||
|
): Iwt2GearProgress | undefined {
|
||||||
|
if (!config.enabled) return undefined
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
IWT2_PARTY_ORDER.map((classId) => [
|
||||||
|
classId,
|
||||||
|
{
|
||||||
|
slots: Object.fromEntries(
|
||||||
|
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
||||||
|
),
|
||||||
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
) as Iwt2GearProgress
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
|
||||||
|
|
||||||
|
export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
|
export const IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
|
export const IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
|
export const IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED = true
|
||||||
|
export const IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED = true
|
||||||
|
|
||||||
|
export const IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||||
|
|
||||||
|
export const IWT2_PVP_STADIUM_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS
|
||||||
|
|
||||||
|
type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
|
||||||
|
|
||||||
|
type TierWeight = {
|
||||||
|
tier: Iwt2RoguelikeBossTier
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Iwt2RoguelikeBossPoolOptions = {
|
||||||
|
bossPool?: readonly Iwt2BossId[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
||||||
|
early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'],
|
||||||
|
mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'],
|
||||||
|
late: ['rimebastion', 'cinderback-ricochet', 'obsidian-ram-golem', 'venom-orchid-hydra', 'sandglass-scorpion'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_ROGUELIKE_BOSS_THREAT: Record<Iwt2RoguelikeBossTier, number> = {
|
||||||
|
early: 1,
|
||||||
|
mid: 2,
|
||||||
|
late: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_ROGUELIKE_BOSS_TIER_BY_ID: Record<Iwt2BossId, Iwt2RoguelikeBossTier> = {
|
||||||
|
bulldrome: 'early',
|
||||||
|
'yian-kut-ku': 'early',
|
||||||
|
'great-jaggi': 'early',
|
||||||
|
rathian: 'early',
|
||||||
|
'stormcoil-wyrm': 'early',
|
||||||
|
khezu: 'mid',
|
||||||
|
barroth: 'mid',
|
||||||
|
'tobi-kadachi': 'mid',
|
||||||
|
'ember-mantis-duelist': 'mid',
|
||||||
|
'crystal-bat-matriarch': 'mid',
|
||||||
|
'hollowcrown-revenant': 'mid',
|
||||||
|
rimebastion: 'late',
|
||||||
|
'cinderback-ricochet': 'late',
|
||||||
|
'obsidian-ram-golem': 'late',
|
||||||
|
'venom-orchid-hydra': 'late',
|
||||||
|
'sandglass-scorpion': 'late',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2PveRoguelikeBossPair(
|
||||||
|
stage: number,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
return createIwt2WeightedRoguelikeBossPair(stage, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2WeightedRoguelikeBossPair(
|
||||||
|
stage: number,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const choices: Iwt2BossId[] = []
|
||||||
|
const maxThreat = maxThreatForStage(stage)
|
||||||
|
const bossPool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
|
|
||||||
|
while (choices.length < count) {
|
||||||
|
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
||||||
|
if (!next) break
|
||||||
|
choices.push(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return choices.length === count
|
||||||
|
? choices
|
||||||
|
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUniformIwt2RoguelikeBossPair(
|
||||||
|
random: () => number = Math.random,
|
||||||
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const pool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
|
const choices: Iwt2BossId[] = []
|
||||||
|
while (pool.length > 0 && choices.length < count) {
|
||||||
|
const index = randomIndex(pool.length, random)
|
||||||
|
const [choice] = pool.splice(index, 1)
|
||||||
|
if (choice) choices.push(choice)
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enabledIwt2RoguelikeBossPool(
|
||||||
|
bossIds: readonly Iwt2BossId[],
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
return normalizeBossPool(bossIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseWeightedBossForStage(
|
||||||
|
stage: number,
|
||||||
|
selected: readonly Iwt2BossId[],
|
||||||
|
maxThreat: number,
|
||||||
|
random: () => number,
|
||||||
|
bossPool: readonly Iwt2BossId[],
|
||||||
|
): Iwt2BossId | undefined {
|
||||||
|
const selectedSet = new Set(selected)
|
||||||
|
const bossPoolSet = new Set(bossPool)
|
||||||
|
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
|
||||||
|
const weightedTiers = tierWeightsForStage(stage)
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => (
|
||||||
|
bossPoolSet.has(bossId)
|
||||||
|
&& !selectedSet.has(bossId)
|
||||||
|
&& selectedThreat + threatForBoss(bossId) <= maxThreat
|
||||||
|
)),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.weight > 0 && entry.bosses.length > 0)
|
||||||
|
|
||||||
|
const totalWeight = weightedTiers.reduce((total, entry) => total + entry.weight, 0)
|
||||||
|
if (totalWeight <= 0) return undefined
|
||||||
|
|
||||||
|
let roll = safeRandom(random) * totalWeight
|
||||||
|
for (const entry of weightedTiers) {
|
||||||
|
roll -= entry.weight
|
||||||
|
if (roll <= 0) {
|
||||||
|
return entry.bosses[randomIndex(entry.bosses.length, random)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEntry = weightedTiers[weightedTiers.length - 1]
|
||||||
|
return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBossPool(bossPool: readonly Iwt2BossId[] | undefined): Iwt2BossId[] {
|
||||||
|
const knownBosses = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||||
|
if (!bossPool) return knownBosses
|
||||||
|
|
||||||
|
const knownBossSet = new Set(knownBosses)
|
||||||
|
const normalized: Iwt2BossId[] = []
|
||||||
|
for (const bossId of bossPool) {
|
||||||
|
if (knownBossSet.has(bossId) && !normalized.includes(bossId)) {
|
||||||
|
normalized.push(bossId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : knownBosses
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierWeightsForStage(stage: number): TierWeight[] {
|
||||||
|
const safeStage = Math.max(1, Math.floor(stage))
|
||||||
|
if (safeStage <= 2) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 85 },
|
||||||
|
{ tier: 'mid', weight: 15 },
|
||||||
|
{ tier: 'late', weight: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (safeStage === 3) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 60 },
|
||||||
|
{ tier: 'mid', weight: 35 },
|
||||||
|
{ tier: 'late', weight: 5 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (safeStage <= 5) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 25 },
|
||||||
|
{ tier: 'mid', weight: 60 },
|
||||||
|
{ tier: 'late', weight: 15 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (safeStage === 6) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 10 },
|
||||||
|
{ tier: 'mid', weight: 50 },
|
||||||
|
{ tier: 'late', weight: 40 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 5 },
|
||||||
|
{ tier: 'mid', weight: 30 },
|
||||||
|
{ tier: 'late', weight: 65 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxThreatForStage(stage: number): number {
|
||||||
|
const safeStage = Math.max(1, Math.floor(stage))
|
||||||
|
if (safeStage <= 2) return 3
|
||||||
|
if (safeStage === 3) return 4
|
||||||
|
if (safeStage <= 5) return 5
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
|
||||||
|
function threatForBoss(bossId: Iwt2BossId): number {
|
||||||
|
return IWT2_ROGUELIKE_BOSS_THREAT[IWT2_ROGUELIKE_BOSS_TIER_BY_ID[bossId]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomIndex(length: number, random: () => number): number {
|
||||||
|
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBossCount(count: number | undefined): number {
|
||||||
|
return Math.max(1, Math.min(3, Math.floor(count ?? 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRandom(random: () => number): number {
|
||||||
|
const value = random()
|
||||||
|
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ type PhaserArenaProps = {
|
|||||||
|
|
||||||
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const onStepRef = useRef(onStep)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStepRef.current = onStep
|
||||||
|
}, [onStep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return undefined
|
if (!hostRef.current) return undefined
|
||||||
@@ -21,7 +26,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
getMovement: () => movementRef.current,
|
getMovement: () => movementRef.current,
|
||||||
getSelectedPartyId: () => selectedPartyIdRef.current,
|
getSelectedPartyId: () => selectedPartyIdRef.current,
|
||||||
getState: () => stateRef.current,
|
getState: () => stateRef.current,
|
||||||
step: onStep,
|
step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
|
||||||
})
|
})
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -40,7 +45,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
return () => {
|
return () => {
|
||||||
game.destroy(true)
|
game.destroy(true)
|
||||||
}
|
}
|
||||||
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
|
}, [movementRef, selectedPartyIdRef, stateRef])
|
||||||
|
|
||||||
return <div className="iwt2-phaser-host" ref={hostRef} />
|
return <div className="iwt2-phaser-host" ref={hostRef} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Iwt2PartyEntityState } from '../sim'
|
||||||
|
import type { Iwt2ClassWeaponLayer } from '../content/classes'
|
||||||
|
|
||||||
|
export type PartyAttackPulse = {
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PartyRenderMotion = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotation: number
|
||||||
|
scaleX: number
|
||||||
|
scaleY: number
|
||||||
|
tint?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PartyWeaponLayerMotion = {
|
||||||
|
offsetXScale: number
|
||||||
|
offsetYScale: number
|
||||||
|
rotation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATTACK_DURATION_SECONDS = 0.22
|
||||||
|
const RANGED_ATTACK_DURATION_SECONDS = 0.16
|
||||||
|
|
||||||
|
export function partyRenderMotion(
|
||||||
|
entity: Iwt2PartyEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
attackPulse?: PartyAttackPulse,
|
||||||
|
): 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 }
|
||||||
|
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
|
||||||
|
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPartyAttackPulseActive(
|
||||||
|
entity: Iwt2PartyEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
attackPulse: PartyAttackPulse,
|
||||||
|
): boolean {
|
||||||
|
return attackProgressFor(entity, timeSeconds, attackPulse) < 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyWeaponLayerMotion(
|
||||||
|
entity: Iwt2PartyEntityState,
|
||||||
|
layer: Iwt2ClassWeaponLayer,
|
||||||
|
timeSeconds: number,
|
||||||
|
attackPulse?: PartyAttackPulse,
|
||||||
|
): PartyWeaponLayerMotion {
|
||||||
|
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
|
||||||
|
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
|
||||||
|
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attackProgressFor(
|
||||||
|
entity: Iwt2PartyEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
attackPulse: PartyAttackPulse,
|
||||||
|
): number {
|
||||||
|
const duration = entity.projectileSpeed > 0 ? RANGED_ATTACK_DURATION_SECONDS : ATTACK_DURATION_SECONDS
|
||||||
|
return Math.max(0, Math.min(1, (timeSeconds - attackPulse.startedAt) / duration))
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@ import type { MovementVector } from '../../../../input'
|
|||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
|
||||||
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
|
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
|
||||||
import { bossRenderMotion } from '../bossAnimation'
|
import { bossRenderMotion } from '../bossAnimation'
|
||||||
|
import {
|
||||||
|
isPartyAttackPulseActive,
|
||||||
|
partyRenderMotion,
|
||||||
|
partyWeaponLayerMotion,
|
||||||
|
type PartyAttackPulse,
|
||||||
|
} from '../partyAnimation'
|
||||||
import type {
|
import type {
|
||||||
Iwt2ArenaEvent,
|
Iwt2ArenaEvent,
|
||||||
Iwt2ArenaIndicator,
|
Iwt2ArenaIndicator,
|
||||||
@@ -27,6 +33,11 @@ type FloatingCombatTextAnchor = {
|
|||||||
position: Iwt2Vec2
|
position: Iwt2Vec2
|
||||||
radius: number
|
radius: number
|
||||||
}
|
}
|
||||||
|
type PartyLayerSprites = {
|
||||||
|
body: Phaser.GameObjects.Image
|
||||||
|
weapons: Map<string, Phaser.GameObjects.Image>
|
||||||
|
accents: Map<string, Phaser.GameObjects.Image>
|
||||||
|
}
|
||||||
|
|
||||||
export class BulldromeArenaScene extends Phaser.Scene {
|
export class BulldromeArenaScene extends Phaser.Scene {
|
||||||
private deps: SceneDeps
|
private deps: SceneDeps
|
||||||
@@ -37,10 +48,14 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
private labels = new Map<string, Phaser.GameObjects.Text>()
|
private labels = new Map<string, Phaser.GameObjects.Text>()
|
||||||
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private partyLayerSprites = new Map<string, PartyLayerSprites>()
|
||||||
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
|
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private partyAttackPulses = new Map<string, PartyAttackPulse>()
|
||||||
|
private livePartyEffectIds = new Set<string>()
|
||||||
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
||||||
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
||||||
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
|
private lastPartyAnimationEventId = 0
|
||||||
private lastFloatingEventId = 0
|
private lastFloatingEventId = 0
|
||||||
private lastStateTime = 0
|
private lastStateTime = 0
|
||||||
private lastHudPublish = 0
|
private lastHudPublish = 0
|
||||||
@@ -60,6 +75,12 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
|
for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
|
||||||
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
|
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
|
||||||
|
if (metadata.arenaLayeredSprite) {
|
||||||
|
this.load.image(classArenaBodySpriteKey(metadata.id), metadata.arenaLayeredSprite.bodyUrl)
|
||||||
|
for (const layer of metadata.arenaLayeredSprite.weaponLayers) {
|
||||||
|
this.load.image(classArenaLayerSpriteKey(metadata.id, layer.id), layer.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
|
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
|
||||||
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
|
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
|
||||||
@@ -83,6 +104,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
|
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
|
||||||
this.drawArena(state)
|
this.drawArena(state)
|
||||||
this.drawTelegraphs(state)
|
this.drawTelegraphs(state)
|
||||||
|
this.updatePartyAnimations(state)
|
||||||
this.drawEntities(state)
|
this.drawEntities(state)
|
||||||
this.drawProjectiles(state)
|
this.drawProjectiles(state)
|
||||||
this.drawFloatingCombatTexts(state)
|
this.drawFloatingCombatTexts(state)
|
||||||
@@ -160,9 +182,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
||||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
||||||
}
|
}
|
||||||
if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
|
const metadata = IWT2_CLASS_METADATA[entity.classId]
|
||||||
|
if (metadata.arenaLayeredSprite && this.textures.exists(classArenaBodySpriteKey(entity.classId))) {
|
||||||
drawPartyUnderlay(graphics, entity, color, alpha)
|
drawPartyUnderlay(graphics, entity, color, alpha)
|
||||||
this.drawPartySprite(entity, alpha)
|
this.drawLayeredPartySprite(entity, alpha, state.time)
|
||||||
|
} else if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
|
||||||
|
drawPartyUnderlay(graphics, entity, color, alpha)
|
||||||
|
this.drawPartySprite(entity, alpha, state.time)
|
||||||
} else {
|
} else {
|
||||||
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
|
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
|
||||||
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
||||||
@@ -187,6 +213,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [id, sprites] of this.partyLayerSprites) {
|
||||||
|
if (!livePartyIds.has(id)) {
|
||||||
|
sprites.body.destroy()
|
||||||
|
for (const sprite of sprites.weapons.values()) sprite.destroy()
|
||||||
|
for (const sprite of sprites.accents.values()) sprite.destroy()
|
||||||
|
this.partyLayerSprites.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [id, label] of this.labels) {
|
for (const [id, label] of this.labels) {
|
||||||
if (!liveLabelIds.has(id)) {
|
if (!liveLabelIds.has(id)) {
|
||||||
label.destroy()
|
label.destroy()
|
||||||
@@ -260,23 +295,203 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
.setAlpha(alpha)
|
.setAlpha(alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number) {
|
private drawLayeredPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
|
||||||
|
const metadata = IWT2_CLASS_METADATA[entity.classId].arenaLayeredSprite
|
||||||
|
if (!metadata) return
|
||||||
|
let sprites = this.partyLayerSprites.get(entity.id)
|
||||||
|
if (!sprites) {
|
||||||
|
sprites = {
|
||||||
|
body: this.add.image(entity.position.x, entity.position.y, classArenaBodySpriteKey(entity.classId)).setOrigin(0.5, 0.66).setDepth(40),
|
||||||
|
weapons: new Map<string, Phaser.GameObjects.Image>(),
|
||||||
|
accents: new Map<string, Phaser.GameObjects.Image>(),
|
||||||
|
}
|
||||||
|
this.partyLayerSprites.set(entity.id, sprites)
|
||||||
|
}
|
||||||
|
const fallback = this.partySprites.get(entity.id)
|
||||||
|
if (fallback) {
|
||||||
|
fallback.destroy()
|
||||||
|
this.partySprites.delete(entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
|
||||||
|
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
|
||||||
|
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)
|
||||||
|
.setFlipX(entity.facing.x < -0.05)
|
||||||
|
.setAlpha(alpha)
|
||||||
|
|
||||||
|
const liveLayerIds = new Set<string>()
|
||||||
|
for (const layer of metadata.weaponLayers) {
|
||||||
|
liveLayerIds.add(layer.id)
|
||||||
|
let sprite = sprites.weapons.get(layer.id)
|
||||||
|
const key = classArenaLayerSpriteKey(entity.classId, layer.id)
|
||||||
|
if (!sprite) {
|
||||||
|
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
|
||||||
|
sprites.weapons.set(layer.id, sprite)
|
||||||
|
}
|
||||||
|
const flipX = entity.facing.x < -0.05
|
||||||
|
const facingSign = flipX ? -1 : 1
|
||||||
|
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
|
||||||
|
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)
|
||||||
|
const displayWidth = layerHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX
|
||||||
|
const displayHeight = layerHeight * motion.scaleY
|
||||||
|
const rotation = motion.rotation + facingSign * layerMotion.rotation
|
||||||
|
this.drawGearTierAccent({
|
||||||
|
alpha,
|
||||||
|
displayHeight,
|
||||||
|
displayWidth,
|
||||||
|
entity,
|
||||||
|
flipX,
|
||||||
|
key,
|
||||||
|
layer,
|
||||||
|
layerId: layer.id,
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
rotation,
|
||||||
|
sprites,
|
||||||
|
})
|
||||||
|
applyMotionTint(sprite, motion.tint)
|
||||||
|
sprite
|
||||||
|
.setTexture(key)
|
||||||
|
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
|
||||||
|
.setPosition(positionX, positionY)
|
||||||
|
.setDisplaySize(displayWidth, displayHeight)
|
||||||
|
.setRotation(rotation)
|
||||||
|
.setFlipX(flipX)
|
||||||
|
.setAlpha(alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, sprite] of sprites.weapons) {
|
||||||
|
if (!liveLayerIds.has(id)) {
|
||||||
|
sprite.destroy()
|
||||||
|
sprites.weapons.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, sprite] of sprites.accents) {
|
||||||
|
if (!liveLayerIds.has(id)) {
|
||||||
|
sprite.destroy()
|
||||||
|
sprites.accents.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGearTierAccent({
|
||||||
|
alpha,
|
||||||
|
displayHeight,
|
||||||
|
displayWidth,
|
||||||
|
entity,
|
||||||
|
flipX,
|
||||||
|
key,
|
||||||
|
layer,
|
||||||
|
layerId,
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
rotation,
|
||||||
|
sprites,
|
||||||
|
}: {
|
||||||
|
alpha: number
|
||||||
|
displayHeight: number
|
||||||
|
displayWidth: number
|
||||||
|
entity: Iwt2PartyEntityState
|
||||||
|
flipX: boolean
|
||||||
|
key: string
|
||||||
|
layer: NonNullable<(typeof IWT2_CLASS_METADATA)[Iwt2PlayerClassId]['arenaLayeredSprite']>['weaponLayers'][number]
|
||||||
|
layerId: string
|
||||||
|
positionX: number
|
||||||
|
positionY: number
|
||||||
|
rotation: number
|
||||||
|
sprites: PartyLayerSprites
|
||||||
|
}) {
|
||||||
|
const tierColor = gearTierAccentColor(gearLayerLevel(entity, layer.slot))
|
||||||
|
let accent = sprites.accents.get(layerId)
|
||||||
|
if (!layer.tierAccentEligible || !tierColor) {
|
||||||
|
if (accent) accent.setVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!accent) {
|
||||||
|
accent = this.add.image(positionX, positionY, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
|
||||||
|
sprites.accents.set(layerId, accent)
|
||||||
|
}
|
||||||
|
accent
|
||||||
|
.setVisible(true)
|
||||||
|
.setTexture(key)
|
||||||
|
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
|
||||||
|
.setPosition(positionX, positionY)
|
||||||
|
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
|
||||||
|
.setRotation(rotation)
|
||||||
|
.setFlipX(flipX)
|
||||||
|
.setTint(tierColor)
|
||||||
|
.setAlpha(alpha * 0.82)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
|
||||||
const key = classArenaSpriteKey(entity.classId)
|
const key = classArenaSpriteKey(entity.classId)
|
||||||
let sprite = this.partySprites.get(entity.id)
|
let sprite = this.partySprites.get(entity.id)
|
||||||
if (!sprite) {
|
if (!sprite) {
|
||||||
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
|
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
|
||||||
this.partySprites.set(entity.id, sprite)
|
this.partySprites.set(entity.id, sprite)
|
||||||
}
|
}
|
||||||
|
const layered = this.partyLayerSprites.get(entity.id)
|
||||||
|
if (layered) {
|
||||||
|
layered.body.destroy()
|
||||||
|
for (const layerSprite of layered.weapons.values()) layerSprite.destroy()
|
||||||
|
for (const layerSprite of layered.accents.values()) layerSprite.destroy()
|
||||||
|
this.partyLayerSprites.delete(entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
const displayHeight = entity.radius * 4.55
|
const displayHeight = entity.radius * 4.55
|
||||||
|
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
|
||||||
|
if (motion.tint) {
|
||||||
|
sprite.setTint(motion.tint)
|
||||||
|
} else {
|
||||||
|
sprite.clearTint()
|
||||||
|
}
|
||||||
sprite
|
sprite
|
||||||
.setTexture(key)
|
.setTexture(key)
|
||||||
.setPosition(entity.position.x, entity.position.y + entity.radius * 0.18)
|
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
|
||||||
.setDisplaySize(displayHeight * (sprite.width / Math.max(1, sprite.height)), displayHeight)
|
.setDisplaySize(
|
||||||
|
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
|
||||||
|
displayHeight * motion.scaleY,
|
||||||
|
)
|
||||||
|
.setRotation(motion.rotation)
|
||||||
.setFlipX(entity.facing.x < -0.05)
|
.setFlipX(entity.facing.x < -0.05)
|
||||||
.setAlpha(alpha)
|
.setAlpha(alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updatePartyAnimations(state: Iwt2ArenaState) {
|
||||||
|
if (state.time < this.lastStateTime) {
|
||||||
|
this.lastPartyAnimationEventId = 0
|
||||||
|
this.partyAttackPulses.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.livePartyEffectIds.clear()
|
||||||
|
for (const member of state.party) this.livePartyEffectIds.add(member.id)
|
||||||
|
for (const event of state.events) {
|
||||||
|
if (event.id <= this.lastPartyAnimationEventId) continue
|
||||||
|
if (event.type === 'partyAttack' && this.livePartyEffectIds.has(event.sourceId)) {
|
||||||
|
this.partyAttackPulses.set(event.sourceId, { startedAt: event.time })
|
||||||
|
}
|
||||||
|
this.lastPartyAnimationEventId = Math.max(this.lastPartyAnimationEventId, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of state.party) {
|
||||||
|
const attackPulse = this.partyAttackPulses.get(member.id)
|
||||||
|
if (attackPulse && !isPartyAttackPulseActive(member, state.time, attackPulse)) {
|
||||||
|
this.partyAttackPulses.delete(member.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of this.partyAttackPulses.keys()) {
|
||||||
|
if (!this.livePartyEffectIds.has(id)) this.partyAttackPulses.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private drawLabel(entity: DrawableEntity) {
|
private drawLabel(entity: DrawableEntity) {
|
||||||
let label = this.labels.get(entity.id)
|
let label = this.labels.get(entity.id)
|
||||||
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
|
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
|
||||||
@@ -432,6 +647,30 @@ function entityIcon(entity: DrawableEntity): string {
|
|||||||
return IWT2_CLASS_METADATA[entity.classId].icon
|
return IWT2_CLASS_METADATA[entity.classId].icon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMotionTint(sprite: Phaser.GameObjects.Image, tint?: number) {
|
||||||
|
if (tint) {
|
||||||
|
sprite.setTint(tint)
|
||||||
|
} else {
|
||||||
|
sprite.clearTint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gearLayerLevel(entity: Iwt2PartyEntityState, slot: string): number {
|
||||||
|
if (slot === 'weapon') return entity.gearLevels?.weapon ?? 0
|
||||||
|
if (slot === 'head') return entity.gearLevels?.helmet ?? 0
|
||||||
|
if (slot === 'chest') return entity.gearLevels?.chest ?? 0
|
||||||
|
if (slot === 'legs') return entity.gearLevels?.legs ?? 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function gearTierAccentColor(level: number): number | undefined {
|
||||||
|
if (level <= 0) return undefined
|
||||||
|
if (level <= 2) return 0x55e887
|
||||||
|
if (level === 3) return 0x58a8ff
|
||||||
|
if (level === 4) return 0xc56cf0
|
||||||
|
return 0xffd25f
|
||||||
|
}
|
||||||
|
|
||||||
function bossSpriteKey(bossId: Iwt2BossId): string {
|
function bossSpriteKey(bossId: Iwt2BossId): string {
|
||||||
return `iwt2-boss-${bossId}`
|
return `iwt2-boss-${bossId}`
|
||||||
}
|
}
|
||||||
@@ -440,6 +679,14 @@ function classArenaSpriteKey(classId: Iwt2PlayerClassId): string {
|
|||||||
return `iwt2-class-${classId}`
|
return `iwt2-class-${classId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function classArenaBodySpriteKey(classId: Iwt2PlayerClassId): string {
|
||||||
|
return `iwt2-class-${classId}-body`
|
||||||
|
}
|
||||||
|
|
||||||
|
function classArenaLayerSpriteKey(classId: Iwt2PlayerClassId, layerId: string): string {
|
||||||
|
return `iwt2-class-${classId}-${layerId}`
|
||||||
|
}
|
||||||
|
|
||||||
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
|
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
|
||||||
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
|
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2GearProgress,
|
createDefaultIwt2GearProgress,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
type Iwt2GearLevel,
|
type Iwt2GearLevel,
|
||||||
type Iwt2GearProgress,
|
type Iwt2GearProgress,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
import {
|
import {
|
||||||
IWT2_INFUSION_ABILITIES,
|
IWT2_INFUSION_ABILITIES,
|
||||||
@@ -97,14 +101,20 @@ export type Iwt2OnlineSaveResult = {
|
|||||||
save: Iwt2Save | null
|
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 {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: 'Healer',
|
name: normalizeCharacterName(characterName),
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
healerStyle: 'dawnweaver',
|
healerStyle: 'dawnweaver',
|
||||||
@@ -126,7 +136,11 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
||||||
return {
|
return {
|
||||||
version: 2,
|
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: {
|
character: {
|
||||||
name: candidate.character?.name || 'Healer',
|
name: candidate.character?.name || 'Healer',
|
||||||
level: Math.max(1, Math.floor(candidate.character?.level ?? 1)),
|
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 {
|
try {
|
||||||
return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null'))
|
return normalizeSave(JSON.parse(serialized))
|
||||||
} catch {
|
} 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) {
|
export function writeIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
|
||||||
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
replaceIwt2Save(save, slot)
|
||||||
...save,
|
}
|
||||||
updatedAt: Date.now(),
|
|
||||||
}))
|
export function replaceIwt2Save(save: Iwt2Save, slot = activeIwt2SaveSlot()) {
|
||||||
|
window.localStorage.setItem(iwt2SaveSlotKey(slot), JSON.stringify(normalizeSave(save)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKill(
|
export function recordIwt2BossKill(
|
||||||
@@ -263,7 +318,7 @@ export function upgradeIwt2GearSlot(
|
|||||||
): Iwt2Save {
|
): Iwt2Save {
|
||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const slot = classProgress.slots[slotId]
|
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 costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
||||||
const inventory = spendInventoryCosts(save.inventory, costs)
|
const inventory = spendInventoryCosts(save.inventory, costs)
|
||||||
return {
|
return {
|
||||||
@@ -294,8 +349,10 @@ export function setIwt2InfusionAbility(
|
|||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
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 (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
|
||||||
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
|
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
|
if (classProgress.infusionAbilityId === abilityId) return save
|
||||||
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
||||||
return {
|
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 {
|
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
||||||
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
||||||
}
|
}
|
||||||
@@ -450,6 +528,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
const classProgress = rawClassProgress as {
|
const classProgress = rawClassProgress as {
|
||||||
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||||
infusionAbilityId?: unknown
|
infusionAbilityId?: unknown
|
||||||
|
passiveInfusionId?: unknown
|
||||||
}
|
}
|
||||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
next[classId].slots[slotId] = {
|
next[classId].slots[slotId] = {
|
||||||
@@ -461,6 +540,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
&& isIwt2InfusionUnlocked(next[classId])
|
&& isIwt2InfusionUnlocked(next[classId])
|
||||||
? infusionAbilityId as Iwt2InfusionAbilityId
|
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||||
: null
|
: null
|
||||||
|
next[classId].passiveInfusionId = classId === 'healer'
|
||||||
|
&& isIwt2PassiveInfusionUnlocked(next)
|
||||||
|
? asPassiveInfusionId(classProgress.passiveInfusionId)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -470,10 +553,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asGearLevel(value: unknown): Iwt2GearLevel {
|
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
|
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[] {
|
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const byId = new Map<string, Iwt2InventoryItem>()
|
const byId = new Map<string, Iwt2InventoryItem>()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
tickIwt2Arena,
|
tickIwt2Arena,
|
||||||
type Iwt2ArenaState,
|
type Iwt2ArenaState,
|
||||||
} from '../sim/arenaState'
|
} from '../sim/arenaState'
|
||||||
import type { Iwt2EntityId } from '../sim'
|
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
|
||||||
import { castIwt2HealerAbility } from '../sim'
|
import { castIwt2HealerAbility } from '../sim'
|
||||||
import { PhaserArena } from '../render/PhaserArena'
|
import { PhaserArena } from '../render/PhaserArena'
|
||||||
import {
|
import {
|
||||||
@@ -45,25 +45,32 @@ import {
|
|||||||
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
||||||
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
} from '../content/roguelike'
|
} from '../content/roguelike'
|
||||||
|
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
|
||||||
|
|
||||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||||
|
type PvpResultReason = 'opponent-defeated' | null
|
||||||
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
||||||
type OverlayNavEntry = {
|
type OverlayNavEntry = {
|
||||||
action: OverlayAction
|
action: OverlayAction
|
||||||
row: number
|
row: number
|
||||||
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'menu', row: 1 },
|
{ action: 'menu', row: 1, column: 0 },
|
||||||
]
|
]
|
||||||
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'requeue', row: 1 },
|
{ action: 'requeue', row: 0, column: 1 },
|
||||||
{ action: 'menu', row: 2 },
|
{ action: 'menu', row: 0, column: 2 },
|
||||||
]
|
]
|
||||||
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
||||||
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
||||||
|
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
|
||||||
|
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
|
||||||
|
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
|
||||||
|
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
|
||||||
|
|
||||||
type BossArenaScreenProps = {
|
type BossArenaScreenProps = {
|
||||||
bossId: Iwt2BossId
|
bossId: Iwt2BossId
|
||||||
@@ -72,22 +79,30 @@ type BossArenaScreenProps = {
|
|||||||
modeLabel?: string
|
modeLabel?: string
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onMainMenu?: () => void
|
||||||
onPvpRequeue?: () => void
|
onPvpRequeue?: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
roguelikeRun?: {
|
roguelikeRun?: {
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
|
greenCoinThreshold: number
|
||||||
onVictory: () => void
|
onVictory: () => void
|
||||||
stage: number
|
stage: number
|
||||||
variant: Iwt2RoguelikeVariant
|
variant: Iwt2RoguelikeVariant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
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 bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
||||||
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
|
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
|
||||||
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
|
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
|
||||||
@@ -102,6 +117,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
|
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
|
||||||
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
|
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
|
||||||
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
|
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
|
||||||
|
const arenaBounds = useMemo(() => createTopScreenArenaBounds(), [])
|
||||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
|
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
bossIds,
|
bossIds,
|
||||||
@@ -109,21 +125,26 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
roguelikeBuffs,
|
roguelikeBuffs,
|
||||||
createPressureState(roguelikeStage, roguelikeContentType),
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
pveGearActive ? save.gearProgress : undefined,
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
))
|
))
|
||||||
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||||
pvpRoguelike
|
pvpRoguelike
|
||||||
? createInitialIwt2ArenaState(
|
? createArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
bossIds,
|
bossIds,
|
||||||
combinedBossHealthScale,
|
combinedBossHealthScale,
|
||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
|
EMPTY_ROGUELIKE_BUFFS,
|
||||||
createPressureState(roguelikeStage, roguelikeContentType),
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
))
|
))
|
||||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||||
|
const [pvpResultReason, setPvpResultReason] = useState<PvpResultReason>(null)
|
||||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||||
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
||||||
@@ -181,15 +202,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
roguelikeBuffs,
|
roguelikeBuffs,
|
||||||
pressureState,
|
pressureState,
|
||||||
pveGearActive ? save.gearProgress : undefined,
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
)
|
)
|
||||||
const nextOpponentState = pvpRoguelike
|
const nextOpponentState = pvpRoguelike
|
||||||
? createInitialIwt2ArenaState(
|
? createArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
bossIds,
|
bossIds,
|
||||||
combinedBossHealthScale,
|
combinedBossHealthScale,
|
||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
|
EMPTY_ROGUELIKE_BUFFS,
|
||||||
pressureState,
|
pressureState,
|
||||||
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
recordedKillIdsRef.current = new Set()
|
recordedKillIdsRef.current = new Set()
|
||||||
@@ -203,30 +228,42 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
setDropAwards([])
|
setDropAwards([])
|
||||||
setPetAwards([])
|
setPetAwards([])
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
|
setPvpResultReason(null)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
|
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
|
||||||
|
|
||||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>, nextPvpResultReason: PvpResultReason = null) => {
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
|
setPvpResultReason(nextPvpResultReason)
|
||||||
|
statusRef.current = nextStatus
|
||||||
setStatus(nextStatus)
|
setStatus(nextStatus)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const resumeArena = useCallback(() => {
|
||||||
|
setPvpResultReason(null)
|
||||||
|
statusRef.current = 'playing'
|
||||||
|
setStatus('playing')
|
||||||
|
}, [])
|
||||||
|
|
||||||
const abilities = useMemo(
|
const abilities = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const baseAbilities = abilitiesForHealer(
|
const baseAbilities = abilitiesForHealer(
|
||||||
save.character.healerStyle,
|
save.character.healerStyle,
|
||||||
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
|
activeGearProgress?.healer.infusionAbilityId ?? null,
|
||||||
)
|
)
|
||||||
const gearAbilities = pveGearActive
|
const gearAbilities = activeGearProgress
|
||||||
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
|
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
||||||
: baseAbilities
|
: baseAbilities
|
||||||
|
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
|
||||||
|
? [activeGearProgress.healer.passiveInfusionId]
|
||||||
|
: []
|
||||||
return applyRoguelikeModifiers(
|
return applyRoguelikeModifiers(
|
||||||
gearAbilities,
|
gearAbilities,
|
||||||
roguelikeRun?.buffs ?? [],
|
[...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
|
||||||
roguelikeRun?.debuffs ?? [],
|
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) => {
|
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||||
@@ -252,12 +289,18 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const active = entries.find((entry) => entry.action === current) ?? entries[0]
|
const active = entries.find((entry) => entry.action === current) ?? entries[0]
|
||||||
const candidates = entries.filter((entry) => {
|
const candidates = entries.filter((entry) => {
|
||||||
if (entry.action === current) return false
|
if (entry.action === current) return false
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
|
||||||
if (action === 'navigateDown') return entry.row > active.row
|
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
|
return false
|
||||||
})
|
})
|
||||||
if (candidates.length === 0) return current
|
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
|
return candidates[0]?.action ?? current
|
||||||
})
|
})
|
||||||
}, [pvpRoguelike])
|
}, [pvpRoguelike])
|
||||||
@@ -268,11 +311,16 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (overlayAction === 'menu') {
|
if (overlayAction === 'menu') {
|
||||||
|
if (statusRef.current === 'paused') {
|
||||||
|
const returnToMainMenu = onMainMenu ?? onBack
|
||||||
|
returnToMainMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
onBack()
|
onBack()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (statusRef.current === 'paused') {
|
if (statusRef.current === 'paused') {
|
||||||
setStatus('playing')
|
resumeArena()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (statusRef.current === 'victory' && roguelikeRun) {
|
if (statusRef.current === 'victory' && roguelikeRun) {
|
||||||
@@ -280,10 +328,10 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resetArena()
|
resetArena()
|
||||||
}, [onBack, onPvpRequeue, resetArena, roguelikeRun])
|
}, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) {
|
||||||
if (action.startsWith('navigate')) {
|
if (action.startsWith('navigate')) {
|
||||||
moveOverlaySelection(action)
|
moveOverlaySelection(action)
|
||||||
return
|
return
|
||||||
@@ -293,10 +341,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
if (statusRef.current === 'paused') setStatus('playing')
|
if (device === 'pc' && statusRef.current === 'paused') resumeArena()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (action === 'pause' || action === 'back') {
|
||||||
|
if (statusRef.current === 'playing') showOverlay('paused')
|
||||||
|
else if (statusRef.current === 'paused') resumeArena()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (statusRef.current === 'playing' && action.startsWith('ability')) {
|
if (statusRef.current === 'playing' && action.startsWith('ability')) {
|
||||||
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
|
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
|
||||||
if (ability) castAbility(ability)
|
if (ability) castAbility(ability)
|
||||||
@@ -320,16 +373,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action.startsWith('targetParty')) {
|
if (action.startsWith('targetParty')) {
|
||||||
|
if (statusRef.current !== 'playing') return
|
||||||
const index = Number(action.replace('targetParty', '')) - 1
|
const index = Number(action.replace('targetParty', '')) - 1
|
||||||
const target = stateRef.current.party[index]
|
const target = stateRef.current.party[index]
|
||||||
if (target) setSelectedPartyId(target.id)
|
if (target) setSelectedPartyId(target.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (device !== 'controller') return
|
|
||||||
if (action === 'pause' || action === 'back') {
|
|
||||||
if (statusRef.current === 'playing') showOverlay('paused')
|
|
||||||
else if (statusRef.current === 'paused') setStatus('playing')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
|
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
|
||||||
@@ -363,7 +412,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
nextRecordedIds.add(defeatedBoss.bossId)
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||||
difficultySlug,
|
difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
|
||||||
experienceMultiplier,
|
experienceMultiplier,
|
||||||
})
|
})
|
||||||
updatedSave = reward.save
|
updatedSave = reward.save
|
||||||
@@ -377,15 +426,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
onSaveUpdated(updatedSave)
|
onSaveUpdated(updatedSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
const playerCleared = next.bosses.every((boss) => boss.health <= 0)
|
||||||
if (pvpRoguelike && roguelikeRun) {
|
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'
|
statusRef.current = 'victory'
|
||||||
setStatus('victory')
|
setStatus('victory')
|
||||||
roguelikeRun.onVictory()
|
roguelikeRun.onVictory()
|
||||||
} else {
|
} else if (!pvpRoguelike && playerDefeated) {
|
||||||
showOverlay('victory')
|
|
||||||
}
|
|
||||||
} else if (next.party.every((member) => member.health <= 0)) {
|
|
||||||
showOverlay('defeat')
|
showOverlay('defeat')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +476,9 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
? 'Rematch'
|
? 'Rematch'
|
||||||
: 'Restart'
|
: 'Restart'
|
||||||
const overlayTitle = status === 'victory'
|
const overlayTitle = status === 'victory'
|
||||||
? `${bossTitle} Down`
|
? pvpResultReason === 'opponent-defeated'
|
||||||
|
? 'CPU Rival Falls'
|
||||||
|
: `${bossTitle} Down`
|
||||||
: status === 'defeat'
|
: status === 'defeat'
|
||||||
? 'Party Defeated'
|
? 'Party Defeated'
|
||||||
: 'Arena Paused'
|
: 'Arena Paused'
|
||||||
@@ -438,12 +495,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
|
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
|
||||||
const pauseCopy = roguelikeRun?.variant === 'pvp'
|
const pauseCopy = roguelikeRun?.variant === 'pvp'
|
||||||
? undefined
|
? undefined
|
||||||
: 'Combat is stopped. Resume the fight or leave the current run.'
|
: 'Combat is stopped. Continue the fight or return to the main menu.'
|
||||||
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
|
|
||||||
? 'Leave'
|
|
||||||
: roguelikeRun
|
|
||||||
? 'Leave Roguelike'
|
|
||||||
: `Leave ${modeLabel ?? 'Arena'}`
|
|
||||||
const dualScreenState = useMemo(
|
const dualScreenState = useMemo(
|
||||||
() => buildIwt2DualScreenCombatState({
|
() => buildIwt2DualScreenCombatState({
|
||||||
abilities,
|
abilities,
|
||||||
@@ -508,23 +560,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
{pauseCopy && <p>{pauseCopy}</p>}
|
{pauseCopy && <p>{pauseCopy}</p>}
|
||||||
<button
|
<button
|
||||||
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
|
||||||
data-controller-nav="skip"
|
|
||||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('primary')}
|
onClick={() => activateOverlayAction('primary')}
|
||||||
onPointerDown={() => setSelectedOverlayAction('primary')}
|
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Resume
|
Continue
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
|
||||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('menu')}
|
onClick={() => activateOverlayAction('menu')}
|
||||||
onPointerDown={() => setSelectedOverlayAction('menu')}
|
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{pauseLeaveLabel}
|
Main Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -638,6 +688,17 @@ function roguelikeBossHealthScale(stage: number): number {
|
|||||||
return 1 + Math.max(0, stage - 1) * 0.1
|
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[] {
|
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||||
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||||
return DEFAULT_OVERLAY_NAV_ENTRIES
|
return DEFAULT_OVERLAY_NAV_ENTRIES
|
||||||
@@ -696,6 +757,7 @@ function createArenaState(
|
|||||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
roguelikePressure: ReturnType<typeof createPressureState>,
|
roguelikePressure: ReturnType<typeof createPressureState>,
|
||||||
gearProgress?: Iwt2Save['gearProgress'],
|
gearProgress?: Iwt2Save['gearProgress'],
|
||||||
|
bounds?: Iwt2ArenaBounds,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
const baseState = createInitialIwt2ArenaState(
|
const baseState = createInitialIwt2ArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
@@ -703,6 +765,7 @@ function createArenaState(
|
|||||||
bossHealthScale,
|
bossHealthScale,
|
||||||
partyDamageTakenScale,
|
partyDamageTakenScale,
|
||||||
roguelikePressure,
|
roguelikePressure,
|
||||||
|
bounds,
|
||||||
)
|
)
|
||||||
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
|
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
|
||||||
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
|
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
|
||||||
@@ -718,6 +781,17 @@ function createArenaState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTopScreenArenaBounds(): Iwt2ArenaBounds {
|
||||||
|
if (typeof window === 'undefined') return { width: 960, height: 540 }
|
||||||
|
const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH
|
||||||
|
&& window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT
|
||||||
|
const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH
|
||||||
|
return {
|
||||||
|
width: Math.max(320, Math.round(window.innerWidth - railWidth)),
|
||||||
|
height: Math.max(240, Math.round(window.innerHeight)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createPressureState(
|
function createPressureState(
|
||||||
stage: number | undefined,
|
stage: number | undefined,
|
||||||
contentType: Iwt2RoguelikeContentType | undefined,
|
contentType: Iwt2RoguelikeContentType | undefined,
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
||||||
import { getGameMode } from '../../../gameRepository'
|
import { getGameMode } from '../../../gameRepository'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2Save,
|
|
||||||
loadIwt2OnlineSave,
|
loadIwt2OnlineSave,
|
||||||
setIwt2InfusionAbility,
|
setIwt2InfusionAbility,
|
||||||
|
setIwt2PassiveInfusion,
|
||||||
updateIwt2CharacterSettings,
|
updateIwt2CharacterSettings,
|
||||||
upgradeIwt2GearSlot,
|
upgradeIwt2GearSlot,
|
||||||
canAffordIwt2Costs,
|
canAffordIwt2Costs,
|
||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
IWT2_HEALER_ORDER,
|
IWT2_HEALER_ORDER,
|
||||||
} from '../content/healerAbilities'
|
} from '../content/healerAbilities'
|
||||||
import {
|
import {
|
||||||
|
buildIwt2SelfBuffChoices,
|
||||||
|
IWT2_REVIVE_PARTY_CHOICE,
|
||||||
type Iwt2RoguelikeChoice,
|
type Iwt2RoguelikeChoice,
|
||||||
type Iwt2RoguelikeContentType,
|
type Iwt2RoguelikeContentType,
|
||||||
type Iwt2RoguelikeOpponentDebuffId,
|
type Iwt2RoguelikeOpponentDebuffId,
|
||||||
@@ -51,9 +53,14 @@ import {
|
|||||||
IWT2_GEAR_SLOT_RECIPES,
|
IWT2_GEAR_SLOT_RECIPES,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
IWT2_GEAR_STAT_LABELS,
|
IWT2_GEAR_STAT_LABELS,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
|
type Iwt2GearStatId,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
import {
|
import {
|
||||||
@@ -143,6 +150,7 @@ type Iwt2GearNavEntry =
|
|||||||
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
||||||
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
||||||
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, 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_HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||||
const IWT2_NAME_MAX_LENGTH = 18
|
const IWT2_NAME_MAX_LENGTH = 18
|
||||||
@@ -1255,7 +1263,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
|||||||
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
|
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'back' || action === 'pause') return
|
if (action === 'back') onBack()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1546,33 +1554,10 @@ export function Iwt2HunterProfileScreen({
|
|||||||
|
|
||||||
function moveSelection(action: InputAction) {
|
function moveSelection(action: InputAction) {
|
||||||
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||||
setSelectedIndex((current) => {
|
const nextIndex = moveHunterProfileSelection(navEntries, selectedIndex, action)
|
||||||
const bounded = Math.min(current, navEntries.length - 1)
|
const nextEntry = navEntries[nextIndex]
|
||||||
const active = navEntries[bounded]
|
setSelectedIndex(nextIndex)
|
||||||
if (!active) return 0
|
if (nextEntry?.kind === 'item') setFocusedItemKey(nextEntry.itemKey)
|
||||||
const candidates = navEntries
|
|
||||||
.map((entry, index) => ({ entry, index }))
|
|
||||||
.filter(({ index }) => index !== bounded)
|
|
||||||
.filter(({ entry }) => {
|
|
||||||
if (action === 'navigateLeft') return entry.column < active.column
|
|
||||||
if (action === 'navigateRight') return entry.column > active.column
|
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
|
||||||
return entry.row > active.row
|
|
||||||
})
|
|
||||||
if (candidates.length === 0) return bounded
|
|
||||||
candidates.sort((a, b) => {
|
|
||||||
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
|
||||||
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
|
||||||
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
||||||
? Math.abs(a.entry.row - active.row)
|
|
||||||
: Math.abs(a.entry.column - active.column)
|
|
||||||
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
||||||
? Math.abs(b.entry.row - active.row)
|
|
||||||
: Math.abs(b.entry.column - active.column)
|
|
||||||
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
|
||||||
})
|
|
||||||
return candidates[0]?.index ?? bounded
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
|
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
|
||||||
@@ -1862,6 +1847,7 @@ function Iwt2ProfileNameEditor({
|
|||||||
<label>
|
<label>
|
||||||
<span>Profile Name</span>
|
<span>Profile Name</span>
|
||||||
<input
|
<input
|
||||||
|
data-controller-nav="skip"
|
||||||
maxLength={IWT2_NAME_MAX_LENGTH}
|
maxLength={IWT2_NAME_MAX_LENGTH}
|
||||||
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
|
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -1900,38 +1886,42 @@ export function Iwt2CloudSaveScreen({
|
|||||||
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
||||||
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
|
||||||
|
|
||||||
|
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
|
||||||
|
setSyncingOnlineSave(true)
|
||||||
|
setMessage('Checking online save...')
|
||||||
|
try {
|
||||||
|
const result = await loadIwt2OnlineSave()
|
||||||
|
if (isCancelled()) return
|
||||||
|
setOnlineSave(result.save)
|
||||||
|
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
|
||||||
|
} catch (reason) {
|
||||||
|
if (isCancelled()) return
|
||||||
|
setOnlineSave(null)
|
||||||
|
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled()) setSyncingOnlineSave(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
if (!onlineBackupsAvailable) {
|
if (!onlineBackupsAvailable) {
|
||||||
setOnlineSave(null)
|
|
||||||
setMessage('Online save unavailable in offline mode.')
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSyncingOnlineSave(true)
|
const timer = window.setTimeout(() => {
|
||||||
setMessage('Checking online save...')
|
void checkOnlineSave(() => cancelled)
|
||||||
loadIwt2OnlineSave()
|
}, 0)
|
||||||
.then((result) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setOnlineSave(result.save)
|
|
||||||
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
|
|
||||||
})
|
|
||||||
.catch((reason) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setOnlineSave(null)
|
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setSyncingOnlineSave(false)
|
|
||||||
})
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [onlineBackupsAvailable])
|
}, [checkOnlineSave, onlineBackupsAvailable])
|
||||||
|
|
||||||
const useLocalSave = useCallback(async () => {
|
const handleUseLocalSave = useCallback(async () => {
|
||||||
if (!onlineBackupsAvailable) {
|
if (!onlineBackupsAvailable) {
|
||||||
setMessage('Using local save. Sign in online to update the server copy.')
|
setMessage('Using local save. Sign in online to update the server copy.')
|
||||||
return
|
return
|
||||||
@@ -1949,16 +1939,11 @@ export function Iwt2CloudSaveScreen({
|
|||||||
}
|
}
|
||||||
}, [onlineBackupsAvailable, save])
|
}, [onlineBackupsAvailable, save])
|
||||||
|
|
||||||
const useOnlineSave = useCallback(() => {
|
const handleUseOnlineSave = useCallback(() => {
|
||||||
if (!onlineSave) return
|
if (!onlineBackupsAvailable || !onlineSave) return
|
||||||
onSaveUpdated(onlineSave)
|
onSaveUpdated(onlineSave)
|
||||||
setMessage('Local save now uses online progress.')
|
setMessage('Local save now uses online progress.')
|
||||||
}, [onlineSave, onSaveUpdated])
|
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
|
||||||
|
|
||||||
const useNewSave = useCallback(() => {
|
|
||||||
onSaveUpdated(createDefaultIwt2Save())
|
|
||||||
setMessage('Started a new local IWT2 save.')
|
|
||||||
}, [onSaveUpdated])
|
|
||||||
|
|
||||||
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
||||||
{
|
{
|
||||||
@@ -1968,7 +1953,7 @@ export function Iwt2CloudSaveScreen({
|
|||||||
value: onlineBackupsAvailable ? 'Upload' : 'Current',
|
value: onlineBackupsAvailable ? 'Upload' : 'Current',
|
||||||
disabled: syncingOnlineSave,
|
disabled: syncingOnlineSave,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
void useLocalSave()
|
void handleUseLocalSave()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1976,30 +1961,22 @@ export function Iwt2CloudSaveScreen({
|
|||||||
label: 'Use Online Save',
|
label: 'Use Online Save',
|
||||||
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
|
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
|
||||||
value: onlineSave ? 'Download' : 'Empty',
|
value: onlineSave ? 'Download' : 'Empty',
|
||||||
disabled: syncingOnlineSave || !onlineSave,
|
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
|
||||||
onConfirm: useOnlineSave,
|
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: useNewSave,
|
|
||||||
},
|
},
|
||||||
], onBack), [
|
], onBack), [
|
||||||
|
handleUseLocalSave,
|
||||||
|
handleUseOnlineSave,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onlineSave,
|
onlineSave,
|
||||||
onBack,
|
onBack,
|
||||||
save,
|
save,
|
||||||
syncingOnlineSave,
|
syncingOnlineSave,
|
||||||
useLocalSave,
|
|
||||||
useNewSave,
|
|
||||||
useOnlineSave,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
|
<Iwt2ScreenShell title="Cloud Backup" onBack={onBack}>
|
||||||
{message && <p className="iwt2-screen-note">{message}</p>}
|
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
|
||||||
<Iwt2ActionList actions={actions} />
|
<Iwt2ActionList actions={actions} />
|
||||||
</Iwt2ScreenShell>
|
</Iwt2ScreenShell>
|
||||||
)
|
)
|
||||||
@@ -2021,14 +1998,23 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
const classProgress = save.gearProgress[selectedClassId]
|
const classProgress = save.gearProgress[selectedClassId]
|
||||||
const selectedSlot = classProgress.slots[selectedSlotId]
|
const selectedSlot = classProgress.slots[selectedSlotId]
|
||||||
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
||||||
|
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
||||||
|
const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)
|
||||||
|
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
||||||
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
||||||
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
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 infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
||||||
const infusionAnchorSlot = selectedSlot.level >= 5
|
const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
|
||||||
|
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
|
||||||
? selectedSlotId
|
? 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 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 navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
||||||
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
||||||
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
||||||
@@ -2050,8 +2036,19 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
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
|
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]
|
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
||||||
|
|
||||||
@@ -2103,6 +2100,18 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
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.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2110,12 +2119,23 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
|
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
|
||||||
<div className="iwt2-gear-layout">
|
<div className="iwt2-gear-layout">
|
||||||
<section className="iwt2-gear-column">
|
<section className="iwt2-gear-column">
|
||||||
|
<button
|
||||||
|
className={`back-button iwt2-gear-back ${activeEntry?.kind === 'back' ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={activeEntry?.kind === 'back' ? 'true' : undefined}
|
||||||
|
onClick={onBack}
|
||||||
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
<div className="iwt2-gear-class-list">
|
<div className="iwt2-gear-class-list">
|
||||||
{IWT2_PARTY_ORDER.map((classId) => {
|
{IWT2_PARTY_ORDER.map((classId) => {
|
||||||
const metadata = IWT2_CLASS_METADATA[classId]
|
const metadata = IWT2_CLASS_METADATA[classId]
|
||||||
const selected = selectedClassId === classId
|
const selected = selectedClassId === classId
|
||||||
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
||||||
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
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 highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
||||||
const className = gearClassDisplayName(classId, save)
|
const className = gearClassDisplayName(classId, save)
|
||||||
const classSubtitle = gearClassSubtitle(classId, save)
|
const classSubtitle = gearClassSubtitle(classId, save)
|
||||||
@@ -2133,7 +2153,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{className}</strong>
|
<strong>{className}</strong>
|
||||||
<small>{classSubtitle}</small>
|
<small>{classSubtitle}</small>
|
||||||
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
|
<small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -2176,7 +2196,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
|
Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2186,6 +2206,16 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong>
|
<strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong>
|
||||||
<small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small>
|
<small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small>
|
||||||
</p>
|
</p>
|
||||||
|
<div className="iwt2-gear-bonus-grid">
|
||||||
|
<span>
|
||||||
|
<strong>Current bonus</strong>
|
||||||
|
<small>{selectedBonus.text}</small>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<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">
|
<div className="iwt2-gear-cost-list">
|
||||||
{upgradeCosts.length === 0 ? (
|
{upgradeCosts.length === 0 ? (
|
||||||
<span>Max rank reached.</span>
|
<span>Max rank reached.</span>
|
||||||
@@ -2221,12 +2251,40 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{ability.name}</strong>
|
<strong>{ability.name}</strong>
|
||||||
<small>{ability.description}</small>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
<footer className="iwt2-gear-message">{message}</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -2245,6 +2303,40 @@ function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string {
|
|||||||
return role === 'damage' ? 'Damage' : 'Tank'
|
return role === 'damage' ? 'Damage' : 'Tank'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gearBonusSummary(
|
||||||
|
statId: Iwt2GearStatId,
|
||||||
|
level: number,
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
): { label: string, text: string, value: string } {
|
||||||
|
const bonus = Math.max(0, level)
|
||||||
|
if (statId === 'maxHealth') return gearBonus('Max health', `+${bonus * 4}%`)
|
||||||
|
if (statId === 'moveSpeed') return gearBonus('Move speed', `+${formatPercent(bonus * 2.5)}%`)
|
||||||
|
if (statId === 'damage') return gearBonus('Attack damage', `+${bonus * 4}%`)
|
||||||
|
if (statId === 'attackCooldown') {
|
||||||
|
const label = classId === 'healer' ? 'Ability cooldown' : 'Attack cooldown'
|
||||||
|
return gearBonus(label, `-${bonus * 3}%`)
|
||||||
|
}
|
||||||
|
if (statId === 'projectileSpeed') return gearBonus('Projectile speed', `+${bonus * 3}%`)
|
||||||
|
if (statId === 'hazardDamageTaken') return gearBonus('Hazard damage taken', `-${bonus * 3}%`)
|
||||||
|
if (statId === 'stunResist') return gearBonus('Stun/knockdown duration', `-${bonus * 8}%`)
|
||||||
|
if (statId === 'healingPower') return gearBonus('Ability healing', `+${bonus * 4}%`)
|
||||||
|
return gearBonus('Bonus', '+0%')
|
||||||
|
}
|
||||||
|
|
||||||
|
function gearBonus(label: string, value: string): { label: string, text: string, value: string } {
|
||||||
|
return { label, text: `${label} ${value}`, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function infusionAnchorText(level: number): string {
|
||||||
|
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 {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
function clampToEnabled(actions: Iwt2NavAction[], index: number) {
|
function clampToEnabled(actions: Iwt2NavAction[], index: number) {
|
||||||
if (actions.length === 0) return 0
|
if (actions.length === 0) return 0
|
||||||
const bounded = Math.min(Math.max(0, index), actions.length - 1)
|
const bounded = Math.min(Math.max(0, index), actions.length - 1)
|
||||||
@@ -2326,6 +2418,39 @@ function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
|
|||||||
return entries[Math.min(index, entries.length - 1)]
|
return entries[Math.min(index, entries.length - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveHunterProfileSelection(
|
||||||
|
entries: Iwt2HunterProfileNavEntry[],
|
||||||
|
current: number,
|
||||||
|
action: InputAction,
|
||||||
|
) {
|
||||||
|
if (!action.startsWith('navigate') || entries.length === 0) return current
|
||||||
|
const bounded = Math.min(current, entries.length - 1)
|
||||||
|
const active = entries[bounded]
|
||||||
|
if (!active) return 0
|
||||||
|
const candidates = entries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== bounded)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < active.column
|
||||||
|
if (action === 'navigateRight') return entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
return entry.row > active.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return bounded
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - active.row)
|
||||||
|
: Math.abs(a.entry.column - active.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - active.row)
|
||||||
|
: Math.abs(b.entry.column - active.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
return candidates[0]?.index ?? bounded
|
||||||
|
}
|
||||||
|
|
||||||
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
|
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
|
||||||
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
|
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
Iwt2EntityId,
|
Iwt2EntityId,
|
||||||
Iwt2GroundHazardState,
|
Iwt2GroundHazardState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
|
Iwt2ArenaBounds,
|
||||||
Iwt2PartyAiRole,
|
Iwt2PartyAiRole,
|
||||||
Iwt2PartyEntityId,
|
Iwt2PartyEntityId,
|
||||||
Iwt2PartyEntityState,
|
Iwt2PartyEntityState,
|
||||||
@@ -66,14 +67,15 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossHealthScale = 1,
|
bossHealthScale = 1,
|
||||||
partyDamageTakenScale = 1,
|
partyDamageTakenScale = 1,
|
||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
const initialBossIds = bossIds?.length ? [...bossIds] : chooseInitialBossIds(bossId)
|
||||||
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
|
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, initialBossIds.length, bossHealthScale, bounds))
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
time: 0,
|
time: 0,
|
||||||
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
bounds,
|
||||||
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
|
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)),
|
||||||
projectiles: [],
|
projectiles: [],
|
||||||
hostileAdds: [],
|
hostileAdds: [],
|
||||||
hazards: [],
|
hazards: [],
|
||||||
@@ -95,9 +97,15 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
|
|||||||
return [primaryBossId, random]
|
return [primaryBossId, random]
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
|
function createBossEntity(
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
index: number,
|
||||||
|
bossCount: number,
|
||||||
|
healthScale: number,
|
||||||
|
bounds: Iwt2ArenaBounds,
|
||||||
|
): Iwt2BossEntityState {
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const position = initialBossPosition(index)
|
const position = scaleArenaPoint(initialBossPosition(index, bossCount), bounds)
|
||||||
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
||||||
return {
|
return {
|
||||||
id: bossId,
|
id: bossId,
|
||||||
@@ -123,9 +131,9 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
|
|||||||
chargeHitEntityIds: [],
|
chargeHitEntityIds: [],
|
||||||
slamApplied: false,
|
slamApplied: false,
|
||||||
wallContactSeconds: 0,
|
wallContactSeconds: 0,
|
||||||
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
|
relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds),
|
||||||
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
||||||
fireballTarget: { x: 320, y: 250 },
|
fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds),
|
||||||
birdWaveThresholdsTriggered: [],
|
birdWaveThresholdsTriggered: [],
|
||||||
mechanicLanes: [],
|
mechanicLanes: [],
|
||||||
mechanicCircles: [],
|
mechanicCircles: [],
|
||||||
@@ -134,13 +142,27 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialBossPosition(index: number) {
|
function initialBossPosition(index: number, bossCount = 2) {
|
||||||
|
if (bossCount > 2) {
|
||||||
|
const angle = -Math.PI * 0.5 + (Math.PI * (index + 0.5)) / bossCount
|
||||||
|
return {
|
||||||
|
x: 690 + Math.cos(angle) * 120,
|
||||||
|
y: DEFAULT_ARENA_HEIGHT * 0.5 + Math.sin(angle) * 165,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
x: index === 0 ? 660 : 760,
|
x: index === 0 ? 660 : 760,
|
||||||
y: index === 0 ? 190 : 345,
|
y: index === 0 ? 190 : 345,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scaleArenaPoint(point: Iwt2Vec2, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
|
||||||
|
return {
|
||||||
|
x: point.x * bounds.width / DEFAULT_ARENA_WIDTH,
|
||||||
|
y: point.y * bounds.height / DEFAULT_ARENA_HEIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
||||||
if (bossId === 'bulldrome') return 2
|
if (bossId === 'bulldrome') return 2
|
||||||
if (bossId === 'great-jaggi') return 2.4
|
if (bossId === 'great-jaggi') return 2.4
|
||||||
@@ -324,7 +346,7 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
|
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number, bounds: Iwt2ArenaBounds): Iwt2PartyEntityState {
|
||||||
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
||||||
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
|
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
|
||||||
return {
|
return {
|
||||||
@@ -332,7 +354,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
|
|||||||
kind: 'party',
|
kind: 'party',
|
||||||
classId: initial.classId,
|
classId: initial.classId,
|
||||||
aiRole: initial.aiRole,
|
aiRole: initial.aiRole,
|
||||||
position: { x: initial.x, y: initial.y },
|
position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds),
|
||||||
velocity: { x: 0, y: 0 },
|
velocity: { x: 0, y: 0 },
|
||||||
facing: { x: 1, y: 0 },
|
facing: { x: 1, y: 0 },
|
||||||
radius: metadata.radius,
|
radius: metadata.radius,
|
||||||
@@ -355,7 +377,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
|
|||||||
attackReady: false,
|
attackReady: false,
|
||||||
status: createEmptyStatus(),
|
status: createEmptyStatus(),
|
||||||
hotEffects: [],
|
hotEffects: [],
|
||||||
preferredOffset: { ...initial.preferredOffset },
|
preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds),
|
||||||
decisionSecondsRemaining: initial.decisionOffset,
|
decisionSecondsRemaining: initial.decisionOffset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -748,6 +770,7 @@ function advanceBossProjectile({
|
|||||||
radius: hitMember.radius + projectile.radius,
|
radius: hitMember.radius + projectile.radius,
|
||||||
}, {
|
}, {
|
||||||
damage: projectile.damage,
|
damage: projectile.damage,
|
||||||
|
damageEventType: 'bossProjectileHit',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
time,
|
time,
|
||||||
})
|
})
|
||||||
@@ -765,13 +788,13 @@ function advanceBossProjectile({
|
|||||||
bounced = true
|
bounced = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bounced) {
|
if (hitMember) {
|
||||||
const puddle = addFirePuddle({
|
const puddle = addFirePuddle({
|
||||||
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
||||||
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
||||||
hazards: nextHazards,
|
hazards: nextHazards,
|
||||||
nextHazardId: nextHazardIdValue,
|
nextHazardId: nextHazardIdValue,
|
||||||
position: hitMember?.position ?? position,
|
position: hitMember.position,
|
||||||
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
time,
|
time,
|
||||||
@@ -846,6 +869,13 @@ function applyPartyAttacks(
|
|||||||
damage: member.attackDamage,
|
damage: member.attackDamage,
|
||||||
remainingSeconds: 1.2,
|
remainingSeconds: 1.2,
|
||||||
})
|
})
|
||||||
|
events.push({
|
||||||
|
id: 0,
|
||||||
|
time,
|
||||||
|
type: 'partyAttack',
|
||||||
|
sourceId: member.id,
|
||||||
|
targetId: target.id,
|
||||||
|
})
|
||||||
projectileId += 1
|
projectileId += 1
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
@@ -876,6 +906,13 @@ function applyPartyAttacks(
|
|||||||
targetId: target.id,
|
targetId: target.id,
|
||||||
value: damage,
|
value: damage,
|
||||||
})
|
})
|
||||||
|
events.push({
|
||||||
|
id: 0,
|
||||||
|
time,
|
||||||
|
type: 'partyAttack',
|
||||||
|
sourceId: member.id,
|
||||||
|
targetId: target.id,
|
||||||
|
})
|
||||||
if (defeated) {
|
if (defeated) {
|
||||||
events.push({
|
events.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
Iwt2ArenaIndicator,
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaInput,
|
Iwt2ArenaInput,
|
||||||
|
Iwt2ArenaBounds,
|
||||||
Iwt2ArenaState as Iwt2CoreArenaState,
|
Iwt2ArenaState as Iwt2CoreArenaState,
|
||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
@@ -61,8 +62,9 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossHealthScale?: number,
|
bossHealthScale?: number,
|
||||||
partyDamageTakenScale?: number,
|
partyDamageTakenScale?: number,
|
||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
bounds?: Iwt2ArenaBounds,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
|
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
|
|||||||
@@ -43,7 +43,16 @@ function applyMemberGearStats(
|
|||||||
gearProgress: Iwt2GearProgress,
|
gearProgress: Iwt2GearProgress,
|
||||||
): Iwt2PartyEntityState {
|
): Iwt2PartyEntityState {
|
||||||
const classProgress = gearProgress[member.classId]
|
const classProgress = gearProgress[member.classId]
|
||||||
let next = { ...member }
|
let next: Iwt2PartyEntityState = {
|
||||||
|
...member,
|
||||||
|
gearLevels: {
|
||||||
|
weapon: classProgress.slots.weapon.level,
|
||||||
|
helmet: classProgress.slots.helmet.level,
|
||||||
|
chest: classProgress.slots.chest.level,
|
||||||
|
legs: classProgress.slots.legs.level,
|
||||||
|
feet: classProgress.slots.feet.level,
|
||||||
|
},
|
||||||
|
}
|
||||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
const level = classProgress.slots[slotId].level
|
const level = classProgress.slots[slotId].level
|
||||||
if (level <= 0) continue
|
if (level <= 0) continue
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function tickGroundHazards({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
damage: hazard.damage,
|
damage: hazard.damage,
|
||||||
|
damageEventType: 'groundHazardTick',
|
||||||
sourceId: hazard.sourceId,
|
sourceId: hazard.sourceId,
|
||||||
time,
|
time,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,18 +9,15 @@ import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
|
|||||||
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
|
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
|
||||||
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
|
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
|
||||||
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
|
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
|
||||||
const ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE = 1.25
|
|
||||||
const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45
|
|
||||||
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
|
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
|
||||||
|
|
||||||
export function roguelikeIncomingDamageScale(
|
export function roguelikeIncomingDamageScale(
|
||||||
_stage: number,
|
stage: number,
|
||||||
contentType: Iwt2RoguelikePressureContentType,
|
contentType: Iwt2RoguelikePressureContentType,
|
||||||
): number {
|
): number {
|
||||||
const base = contentType === 'dungeon'
|
void stage
|
||||||
? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE
|
void contentType
|
||||||
: ROGUELIKE_RAID_DAMAGE_BASE_SCALE
|
return 1
|
||||||
return base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRoguelikePressureState(
|
export function createRoguelikePressureState(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||||
|
import type { Iwt2GearLevel, Iwt2GearSlotId } from '../content/gear'
|
||||||
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
|
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2Vec2 = {
|
export type Iwt2Vec2 = {
|
||||||
@@ -70,6 +71,7 @@ export type Iwt2PartyEntityState = {
|
|||||||
mana: number
|
mana: number
|
||||||
maxMana: number
|
maxMana: number
|
||||||
damageDone: number
|
damageDone: number
|
||||||
|
gearLevels?: Partial<Record<Iwt2GearSlotId, Iwt2GearLevel>>
|
||||||
attackCooldownRemaining: number
|
attackCooldownRemaining: number
|
||||||
castSecondsRemaining: number
|
castSecondsRemaining: number
|
||||||
attackReady: boolean
|
attackReady: boolean
|
||||||
@@ -283,8 +285,11 @@ export type Iwt2GroundHazardState = {
|
|||||||
export type Iwt2ArenaEventType =
|
export type Iwt2ArenaEventType =
|
||||||
| 'bossDamaged'
|
| 'bossDamaged'
|
||||||
| 'healerSpellCast'
|
| 'healerSpellCast'
|
||||||
|
| 'partyAttack'
|
||||||
| 'partyHealed'
|
| 'partyHealed'
|
||||||
| 'partyDamaged'
|
| 'partyDamaged'
|
||||||
|
| 'bossProjectileHit'
|
||||||
|
| 'groundHazardTick'
|
||||||
| 'partyStunned'
|
| 'partyStunned'
|
||||||
| 'bossChargeStart'
|
| 'bossChargeStart'
|
||||||
| 'bossChargeHit'
|
| 'bossChargeHit'
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const BIRD_COUNT = 3
|
|||||||
const BIRD_MELEE_RANGE = 24
|
const BIRD_MELEE_RANGE = 24
|
||||||
const BIRD_MELEE_COOLDOWN = 1.15
|
const BIRD_MELEE_COOLDOWN = 1.15
|
||||||
const BIRD_FLIGHT_DAMAGE = 26
|
const BIRD_FLIGHT_DAMAGE = 26
|
||||||
const YIAN_FIREBALL_BOUNCES = 8
|
const YIAN_FIREBALL_BOUNCES = 3
|
||||||
const YIAN_SAFE_WALL_MARGIN = 118
|
const YIAN_SAFE_WALL_MARGIN = 118
|
||||||
const YIAN_CENTER_CAST_DISTANCE = 36
|
const YIAN_CENTER_CAST_DISTANCE = 36
|
||||||
const YIAN_CENTER_CHARGE_SPEED = 430
|
const YIAN_CENTER_CHARGE_SPEED = 430
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 75 KiB |