Compare commits

...
6 Commits
Author SHA1 Message Date
Warren H ff64c6ddcc Android build v1.1.32 2026-07-07 15:07:14 -04:00
Warren H a6cca2810c Android build v1.1.31 2026-07-06 23:49:14 -04:00
Warren H f48e6130b8 Android build v1.1.30 2026-07-06 15:35:18 -04:00
Warren H 61de17d63e Android build v1.1.29 2026-07-06 15:31:42 -04:00
Warren H 5629041879 Android build v1.1.29 2026-07-06 15:30:04 -04:00
Warren H ed3f43c93f Android build v1.1.28 2026-07-06 13:03:33 -04:00
33 changed files with 453 additions and 106 deletions
+4
View File
@@ -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.
+31 -3
View File
@@ -54,12 +54,18 @@ code updates to be a Git pull plus app restart.
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**. 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/...`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 107 versionCode 113
versionName "1.1.27" versionName "1.1.32"
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.
+4
View File
@@ -104,6 +104,10 @@ 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+59 -15
View File
@@ -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);
@@ -1401,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));
@@ -1640,6 +1672,11 @@
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;
@@ -1735,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;
} }
@@ -1782,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 {
@@ -1803,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;
+18 -2
View File
@@ -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)
+24 -5
View File
@@ -46,6 +46,13 @@ import {
type Iwt2RoguelikeSelfBuffId, type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant, type Iwt2RoguelikeVariant,
} from './content/roguelike' } from './content/roguelike'
import {
createIwt2WeightedRoguelikeBossPair,
createUniformIwt2RoguelikeBossPair,
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'
@@ -522,7 +529,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType, contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState { ): Iwt2RoguelikeRunState {
return { return {
bossIds: createRandomRoguelikeBossPair(), bossIds: createRoguelikeBossPair(variant, contentType, 1),
buffs: [], buffs: [],
contentType, contentType,
debuffs: [], debuffs: [],
@@ -590,15 +597,27 @@ function applyRoguelikeChoice(
return { return {
...run, ...run,
...nextBase, ...nextBase,
bossIds: createRandomRoguelikeBossPair(), bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
...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,
): 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
if (weightedProgressionEnabled) {
return createIwt2WeightedRoguelikeBossPair(stage)
}
return createUniformIwt2RoguelikeBossPair()
} }
function chooseRunChoices<T>(items: readonly T[], count: number): T[] { function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
+3
View File
@@ -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>
) )
} }
+1 -2
View File
@@ -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} />
)} )}
+2 -2
View File
@@ -162,8 +162,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png', url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
drawOrder: 'front', drawOrder: 'front',
heightScale: 1.72, heightScale: 1.72,
offsetXScale: 0.48, offsetXScale: 0.38,
offsetYScale: -2.05, offsetYScale: -0.88,
originX: 0.18, originX: 0.18,
originY: 0.5, originY: 0.5,
attackMotion: { attackMotion: {
@@ -0,0 +1,169 @@
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
type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
type TierWeight = {
tier: Iwt2RoguelikeBossTier
weight: 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,
): Iwt2BossId[] {
const choices: Iwt2BossId[] = []
const maxThreat = maxThreatForStage(stage)
while (choices.length < 2) {
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random)
if (!next) break
choices.push(next)
}
return choices.length === 2
? choices
: createUniformIwt2RoguelikeBossPair(random)
}
export function createUniformIwt2RoguelikeBossPair(random: () => number = Math.random): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
const choices: Iwt2BossId[] = []
while (pool.length > 0 && choices.length < 2) {
const index = randomIndex(pool.length, random)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
function chooseWeightedBossForStage(
stage: number,
selected: readonly Iwt2BossId[],
maxThreat: number,
random: () => number,
): Iwt2BossId | undefined {
const selectedSet = new Set(selected)
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
const weightedTiers = tierWeightsForStage(stage)
.map((entry) => ({
...entry,
bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => (
!selectedSet.has(bossId) && selectedThreat + threatForBoss(bossId) <= maxThreat
)),
}))
.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 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 6
}
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 safeRandom(random: () => number): number {
const value = random()
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
}
+25 -2
View File
@@ -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 {
@@ -64,6 +64,10 @@ const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
] ]
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
@@ -103,6 +107,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,
@@ -111,6 +116,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
roguelikeBuffs, roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType), createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined, pveGearActive ? save.gearProgress : undefined,
arenaBounds,
)) ))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => ( const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike pvpRoguelike
@@ -120,6 +126,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedBossHealthScale, combinedBossHealthScale,
combinedDamageScale, combinedDamageScale,
createPressureState(roguelikeStage, roguelikeContentType), createPressureState(roguelikeStage, roguelikeContentType),
arenaBounds,
) )
: null : null
)) ))
@@ -183,6 +190,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
roguelikeBuffs, roguelikeBuffs,
pressureState, pressureState,
pveGearActive ? save.gearProgress : undefined, pveGearActive ? save.gearProgress : undefined,
arenaBounds,
) )
const nextOpponentState = pvpRoguelike const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState( ? createInitialIwt2ArenaState(
@@ -191,6 +199,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedBossHealthScale, combinedBossHealthScale,
combinedDamageScale, combinedDamageScale,
pressureState, pressureState,
arenaBounds,
) )
: null : null
recordedKillIdsRef.current = new Set() recordedKillIdsRef.current = new Set()
@@ -205,7 +214,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
setPetAwards([]) setPetAwards([])
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
setStatus('playing') setStatus('playing')
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress]) }, [arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => { const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary') setSelectedOverlayAction('primary')
@@ -337,6 +346,7 @@ 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)
@@ -701,6 +711,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,
@@ -708,6 +719,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)
@@ -723,6 +735,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,
+85 -60
View File
@@ -1256,7 +1256,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 (
@@ -1547,33 +1547,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) {
@@ -1863,6 +1840,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}
@@ -1901,38 +1879,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
@@ -1950,13 +1932,13 @@ 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(() => { const handleUseNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save()) onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.') setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated]) }, [onSaveUpdated])
@@ -1969,7 +1951,7 @@ export function Iwt2CloudSaveScreen({
value: onlineBackupsAvailable ? 'Upload' : 'Current', value: onlineBackupsAvailable ? 'Upload' : 'Current',
disabled: syncingOnlineSave, disabled: syncingOnlineSave,
onConfirm: () => { onConfirm: () => {
void useLocalSave() void handleUseLocalSave()
}, },
}, },
{ {
@@ -1977,30 +1959,30 @@ 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', key: 'new-save',
label: 'Use New Save File', label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.', detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New', value: 'New',
onConfirm: useNewSave, onConfirm: handleUseNewSave,
}, },
], onBack), [ ], onBack), [
handleUseLocalSave,
handleUseNewSave,
handleUseOnlineSave,
onlineBackupsAvailable, onlineBackupsAvailable,
onlineSave, onlineSave,
onBack, onBack,
save, save,
syncingOnlineSave, syncingOnlineSave,
useLocalSave,
useNewSave,
useOnlineSave,
]) ])
return ( return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}> <Iwt2ScreenShell title="Backup Slot" 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>
) )
@@ -2114,6 +2096,16 @@ 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]
@@ -2372,6 +2364,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)
} }
+19 -10
View File
@@ -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.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale)) const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, 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,9 @@ 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, 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), 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 +125,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: [],
@@ -141,6 +143,13 @@ function initialBossPosition(index: number) {
} }
} }
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 +333,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 +341,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 +364,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,
} }
} }
+3 -1
View File
@@ -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 {
+4 -2
View File
@@ -12,9 +12,11 @@ const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
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 {
void stage
void contentType
return 1 return 1
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 75 KiB