Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff64c6ddcc | ||
|
|
a6cca2810c |
@@ -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
@@ -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.
@@ -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 111
|
versionCode 113
|
||||||
versionName "1.1.30"
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ import {
|
|||||||
type Iwt2RoguelikeVariant,
|
type Iwt2RoguelikeVariant,
|
||||||
} from './content/roguelike'
|
} from './content/roguelike'
|
||||||
import {
|
import {
|
||||||
createIwt2PveRoguelikeBossPair,
|
createIwt2WeightedRoguelikeBossPair,
|
||||||
createUniformIwt2RoguelikeBossPair,
|
createUniformIwt2RoguelikeBossPair,
|
||||||
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
} from './content/roguelikeBossProgression'
|
} from './content/roguelikeBossProgression'
|
||||||
|
|
||||||
type Iwt2Screen =
|
type Iwt2Screen =
|
||||||
@@ -527,7 +529,7 @@ function createRoguelikeRun(
|
|||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
): Iwt2RoguelikeRunState {
|
): Iwt2RoguelikeRunState {
|
||||||
return {
|
return {
|
||||||
bossIds: createRoguelikeBossPair(variant, 1),
|
bossIds: createRoguelikeBossPair(variant, contentType, 1),
|
||||||
buffs: [],
|
buffs: [],
|
||||||
contentType,
|
contentType,
|
||||||
debuffs: [],
|
debuffs: [],
|
||||||
@@ -595,15 +597,25 @@ function applyRoguelikeChoice(
|
|||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
...nextBase,
|
...nextBase,
|
||||||
bossIds: createRoguelikeBossPair(run.variant, run.stage + 1),
|
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 createRoguelikeBossPair(variant: Iwt2RoguelikeVariant, stage: number): Iwt2BossId[] {
|
function createRoguelikeBossPair(
|
||||||
if (variant === 'pve' && IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED) {
|
variant: Iwt2RoguelikeVariant,
|
||||||
return createIwt2PveRoguelikeBossPair(stage)
|
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()
|
return createUniformIwt2RoguelikeBossPair()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
|
||||||
|
|
||||||
export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
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 Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
|
||||||
|
|
||||||
@@ -43,6 +45,13 @@ const IWT2_ROGUELIKE_BOSS_TIER_BY_ID: Record<Iwt2BossId, Iwt2RoguelikeBossTier>
|
|||||||
export function createIwt2PveRoguelikeBossPair(
|
export function createIwt2PveRoguelikeBossPair(
|
||||||
stage: number,
|
stage: number,
|
||||||
random: () => number = Math.random,
|
random: () => number = Math.random,
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
return createIwt2WeightedRoguelikeBossPair(stage, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2WeightedRoguelikeBossPair(
|
||||||
|
stage: number,
|
||||||
|
random: () => number = Math.random,
|
||||||
): Iwt2BossId[] {
|
): Iwt2BossId[] {
|
||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
const maxThreat = maxThreatForStage(stage)
|
const maxThreat = maxThreatForStage(stage)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user