Compare commits

...
9 Commits
Author SHA1 Message Date
Warren H 679900e7f5 Android build v1.1.33 2026-07-07 16:41:45 -04:00
Warren H ff64c6ddcc Android build v1.1.32 2026-07-07 15:07:14 -04:00
Warren H a6cca2810c Android build v1.1.31 2026-07-06 23:49:14 -04:00
Warren H f48e6130b8 Android build v1.1.30 2026-07-06 15:35:18 -04:00
Warren H 61de17d63e Android build v1.1.29 2026-07-06 15:31:42 -04:00
Warren H 5629041879 Android build v1.1.29 2026-07-06 15:30:04 -04:00
Warren H ed3f43c93f Android build v1.1.28 2026-07-06 13:03:33 -04:00
Warren H d70941f43e Android build v1.1.27 2026-07-06 11:46:53 -04:00
Warren H de2f86e101 Android build v1.1.26 2026-07-05 23:31:04 -04:00
50 changed files with 1246 additions and 195 deletions
+4
View File
@@ -15,6 +15,9 @@
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
- Focus state must always be visible and predictable when controller navigation is active.
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
- Controller navigation fixes must not break live combat bindings. When changing shared input routing, verify that combat-only actions still win during active combat: movement, ability casts, party targeting, pause, and overlay navigation must all work on AYN Thor controls.
- `data-game-nav-active` and other custom navigation markers may coexist with live combat HUDs. Do not treat those markers as menu mode during active combat unless a pause/dialog/result overlay is visible.
- Before considering any controller input change complete, test both states: no-overlay combat and menu/dialog navigation. No-overlay combat must include at least one successful ability cast from a controller binding and continuous analog movement on the AYN Thor control scheme.
## Architecture Requirements
@@ -34,6 +37,7 @@
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
- IWT2 combat must preserve AYN Thor face/shoulder button ability bindings even when party frames, HUD controls, or target rails use custom controller navigation state. Menu navigation priority must never swallow ability inputs while `data-combat-active="true"` and no overlay is open.
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
+31 -3
View File
@@ -54,12 +54,18 @@ code updates to be a Git pull plus app restart.
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
Repository:
Repository URL:
```text
https://git.whoagland.com/phenom/i-want-to-heal.git
```
Local Gitea repository path on the same TrueNAS host:
```text
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
```
TrueNAS paths:
```text
@@ -67,12 +73,15 @@ TrueNAS paths:
/mnt/usbssds/apps/iwanttoheal/data
```
Create the app directory and clone the repo:
Create the app directory and clone the repo. When Gitea is hosted on the same
TrueNAS box, prefer the local repository path instead of HTTPS. This avoids
Git-over-HTTPS TLS failures while downloading large pack files.
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
cd /mnt/usbssds/apps/iwanttoheal
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git app
```
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
@@ -101,6 +110,25 @@ cd /mnt/usbssds/apps/iwanttoheal/app
git pull
```
If setting up a different app hosted by the same Gitea instance, first find its
bare repository path:
```sh
sudo find /mnt -type d -name "REPO-NAME.git" -prune -print 2>/dev/null
```
Then substitute that path in the `safe.directory` and `git clone` commands:
```sh
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git app
sudo chown -R truenas_admin:truenas_admin app
```
`safe.directory` is a Git trust exception for the Gitea-owned bare repository.
It is needed because Gitea's repository files are owned by the app/container
user, not by `truenas_admin`.
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 105
versionName "1.1.26"
versionCode 114
versionName "1.1.33"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+10
View File
@@ -96,8 +96,18 @@ curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$REL
-F "attachment=@$APK"
```
If the CLI release fails after creating the Gitea release but before uploading the APK asset, retry only the release upload:
```sh
npm run release:cli -- --release-only 1.1.26
```
## Step 5: Update TrueNAS
The TrueNAS app checkout should be cloned from the local Gitea bare repository
path when Gitea runs on the same TrueNAS host. After that setup, normal updates
still use `git pull`; the remote is local filesystem storage instead of HTTPS.
```sh
cd /mnt/usbssds/apps/iwanttoheal/app
git pull
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.
+44 -15
View File
@@ -5,6 +5,7 @@ import json
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
@@ -153,6 +154,48 @@ def gitea_request(
except urllib.error.HTTPError as exc:
data = exc.read()
return exc.code, data
except urllib.error.URLError as exc:
raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc
def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]:
curl = shutil.which("curl")
if curl is None:
raise ReleaseError("curl is required for Gitea asset uploads")
url = config.gitea_url.rstrip("/") + path
proc = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"2",
"--request",
"POST",
url,
"--header",
f"Authorization: token {config.token}",
"--form",
f"attachment=@{apk_path}",
"--write-out",
"\n%{http_code}",
],
check=False,
capture_output=True,
)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
try:
body, status_text = proc.stdout.rsplit(b"\n", 1)
return int(status_text), body
except ValueError as exc:
output = proc.stdout.decode("utf-8", "replace")
raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc
def parse_json_response(status: int, data: bytes, action: str) -> dict:
@@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N
raise ReleaseError(f"APK not found for release upload: {apk_path}")
release_id = find_or_create_release(config, log)
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = header + apk_path.read_bytes() + footer
asset_name = urllib.parse.quote(apk_path.name)
path = (
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
f"/releases/{release_id}/assets?name={asset_name}"
)
status, data = gitea_request(
config,
"POST",
path,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
status, data = gitea_upload_asset(config, path, apk_path)
if status == 409:
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
parse_json_response(status, data, "Upload release asset")
+98 -25
View File
@@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
parser.add_argument(
"--release-only",
metavar="VERSION",
help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
if args.release_only and args.reset_player_data:
parser.error("--release-only cannot be combined with --reset-player-data")
return args
@@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) -
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
body += apk.read_bytes()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
url = GITEA_URL + path
curl = shutil.which("curl")
if curl is None:
raise SystemExit("curl is required for Gitea asset uploads")
proc = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"2",
"--request",
"POST",
url,
"--header",
f"Authorization: token {token}",
"--form",
f"attachment=@{apk}",
"--write-out",
"\n%{http_code}",
],
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
detail = proc.stderr.strip() or proc.stdout.strip()
raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
try:
body, status_text = proc.stdout.rsplit("\n", 1)
status = int(status_text)
except ValueError as exc:
raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc
if status >= 400:
raise SystemExit(f"Gitea asset upload failed ({status}): {body}")
try:
parsed = json.loads(body or "{}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc
if not isinstance(parsed, dict):
raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}")
return parsed
def find_or_create_release(version: str, token: str) -> int:
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
tag = f"v{version}"
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
release = json.loads(resp.read().decode("utf-8"))
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
print(f"Gitea release exists: {tag} (id {release_id})")
return int(release_id)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
if exc.code != 404:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json(
"POST",
repo_path + "/releases",
token,
{
"tag_name": f"v{version}",
"tag_name": tag,
"target_commitish": BRANCH,
"name": f"v{version}",
"name": tag,
"body": f"I Want to Heal Android build v{version}",
"draft": False,
"prerelease": False,
@@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None:
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
print(f"Gitea release created: {tag} (id {release_id})")
return int(release_id)
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release_id = find_or_create_release(version, token)
asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}")
@@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT)
if args.release_only:
version = args.release_only.strip()
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
if not apk.exists():
raise SystemExit(f"APK not found: {apk}")
create_gitea_release(version, apk)
return 0
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
+94 -15
View File
@@ -212,6 +212,8 @@
}
.iwt2-arena-layout {
display: grid;
grid-template-columns: 172px minmax(0, 1fr);
height: 100vh;
overflow: hidden;
padding: 0;
@@ -221,6 +223,7 @@
.iwt2-arena-stage {
background: #090c10;
border: 0;
grid-column: 2;
height: 100%;
min-height: 0;
overflow: hidden;
@@ -317,6 +320,22 @@
top: 0;
}
.iwt2-bar em {
color: #fff7df;
font-size: 0.56rem;
font-style: normal;
font-weight: 800;
left: 0;
line-height: 1;
pointer-events: none;
position: absolute;
right: 0;
text-align: center;
text-shadow: 0 1px 0 #050608;
top: 50%;
transform: translateY(-50%);
}
.iwt2-bar.boss span {
background: #dc5162;
}
@@ -337,17 +356,18 @@
.iwt2-party-list {
background: rgba(15, 19, 26, 0.88);
border: 0;
left: 28px;
max-height: calc(100% - 112px);
bottom: 0;
left: 0;
max-height: none;
outline: 0;
padding: 6px;
position: absolute;
top: 28px;
position: fixed;
top: 0;
width: 172px;
z-index: 4;
display: grid;
gap: 8px;
align-content: start;
align-content: center;
overflow: auto;
}
@@ -1099,7 +1119,8 @@
.iwt2-gear-class,
.iwt2-gear-slot,
.iwt2-infusion-row,
.iwt2-gear-upgrade-button {
.iwt2-gear-upgrade-button,
.iwt2-gear-back {
background: #151922;
border: 2px solid #08090d;
color: var(--ink);
@@ -1177,7 +1198,8 @@
.iwt2-gear-class.game-selected,
.iwt2-gear-slot.game-selected,
.iwt2-infusion-row.game-selected,
.iwt2-gear-upgrade-button.game-selected {
.iwt2-gear-upgrade-button.game-selected,
.iwt2-gear-back.game-selected {
outline-color: #e5b95f;
}
@@ -1187,6 +1209,12 @@
text-align: center;
}
.iwt2-gear-back {
min-height: 36px;
padding: 8px 10px;
text-align: center;
}
.iwt2-gear-upgrade-button:disabled,
.iwt2-infusion-row:disabled {
color: var(--muted);
@@ -1219,6 +1247,28 @@
padding: 6px 8px;
}
.iwt2-gear-bonus-grid {
display: grid;
gap: 7px;
}
.iwt2-gear-bonus-grid span {
background: #0d1118;
border: 1px solid #29313d;
display: block;
padding: 7px 8px;
}
.iwt2-gear-bonus-grid strong {
color: #f2c96d;
font-size: 0.62rem;
}
.iwt2-gear-bonus-grid small {
color: #e5edf6;
font-size: 0.62rem;
}
.iwt2-gear-cost-list span.met {
color: #9ef0bd;
}
@@ -1379,6 +1429,10 @@
}
@media (max-width: 1000px) and (max-height: 620px) {
.iwt2-arena-layout {
grid-template-columns: 154px minmax(0, 1fr);
}
.game-shell.iwt2-shell {
padding: 6px 0;
width: min(100%, calc(100% - 20px));
@@ -1618,11 +1672,29 @@
padding: 7px 9px;
}
.iwt2-gear-back {
min-height: 30px;
padding: 5px 8px;
}
.iwt2-gear-detail {
gap: 6px;
padding: 7px;
}
.iwt2-gear-bonus-grid {
gap: 5px;
}
.iwt2-gear-bonus-grid span {
padding: 5px 6px;
}
.iwt2-gear-bonus-grid strong,
.iwt2-gear-bonus-grid small {
font-size: 0.52rem;
}
.iwt2-infusion-row {
gap: 7px;
grid-template-columns: 28px minmax(0, 1fr);
@@ -1700,20 +1772,22 @@
}
.iwt2-party-list {
gap: 5px;
left: 10px;
max-height: calc(100% - 84px);
align-content: center;
gap: 7px;
bottom: 0;
left: 0;
max-height: none;
overflow: hidden;
padding: 4px;
top: 10px;
top: 0;
width: 154px;
}
.iwt2-party-row {
gap: 5px;
grid-template-columns: 22px minmax(0, 1fr);
height: 62px;
min-height: 62px;
height: 70px;
min-height: 70px;
padding: 4px;
}
@@ -1747,8 +1821,12 @@
}
.iwt2-party-row .iwt2-bar {
height: 7px;
margin-top: 2px;
height: 12px;
margin-top: 3px;
}
.iwt2-party-row .iwt2-bar.mana {
height: 8px;
}
.iwt2-party-effects {
@@ -1768,6 +1846,7 @@
.iwt2-ability-bar {
bottom: 8px;
display: none;
gap: 8px;
grid-template-columns: repeat(6, 36px);
max-width: 256px;
+6 -4
View File
@@ -266,6 +266,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode = 'ability',
roguelikeEncounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
difficulty: Difficulty
@@ -279,6 +280,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
roguelikeEncounterPool?: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const staticEncounters = useMemo(
@@ -1399,10 +1401,10 @@ export function CombatScreen({
<div>
<p className="eyebrow">Game Paused</p>
<h2>{dungeon.name}</h2>
<p>Combat is stopped. Resume the fight or leave the current run.</p>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">
Leave {contentName}
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
<button onClick={() => setPaused(false)} type="button">Continue</button>
<button className="secondary-result-button" onClick={onMainMenu} type="button">
Main Menu
</button>
</div>
</div>
+14 -5
View File
@@ -294,6 +294,7 @@ export function PvPRoguelikeScreen({
contentType,
encounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
profile: CharacterProfile
@@ -301,6 +302,7 @@ export function PvPRoguelikeScreen({
contentType: PvpContentType
encounterPool: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
@@ -1423,14 +1425,14 @@ export function PvPRoguelikeScreen({
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'pauseLeave') onMainMenu()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (status === 'queueing') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
@@ -1447,6 +1449,13 @@ export function PvPRoguelikeScreen({
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'round-countdown') {
if (action === 'back' || action === 'pause') {
setOverlaySelectedIndex(0)
setPaused(true)
}
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
@@ -1780,16 +1789,16 @@ export function PvPRoguelikeScreen({
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
Continue
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onClick={onMainMenu}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
Main Menu
</button>
</div>
</div>
+18 -2
View File
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
).some(isVisible)
}
function isDedicatedNavigationAction(action: InputAction) {
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
function dispatchGameAction(action: InputAction, device: InputDevice) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device },
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
if (
device === 'controller'
&& dedicatedNavAction
&& !keyboardInputRef.current
&& hasDedicatedGameNavigation()
) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device)
return
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateRight',
'confirm',
'back',
'pause',
] satisfies InputAction[]
const directTargetActions = [
'targetParty1',
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
const dedicatedNavigationActive = hasDedicatedGameNavigation() && (!combatActive || uiOverlay)
const action = dedicatedNavigationActive
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
?? (DPAD_NAV_ACTIONS[token] && isDedicatedNavigationAction(DPAD_NAV_ACTIONS[token])
? DPAD_NAV_ACTIONS[token]
: undefined)
: DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? DPAD_NAV_ACTIONS[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
+5
View File
@@ -860,6 +860,7 @@ function IWantToHeal1App({
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onMainMenu={() => setScreen('menu')}
onProfileUpdated={setProfile}
/>
</Suspense>
@@ -893,6 +894,10 @@ function IWantToHeal1App({
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onMainMenu={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setScreen('menu')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
+25 -5
View File
@@ -46,6 +46,13 @@ import {
type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant,
} 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 =
| 'menu'
@@ -258,6 +265,7 @@ export function IWantToHeal2App({
}}
save={save}
onBack={() => setScreen('roguelike')}
onMainMenu={() => setScreen('menu')}
onPvpRequeue={() => {
setScreen('roguelike')
startIwt2RoguelikeRun()
@@ -521,7 +529,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState {
return {
bossIds: createRandomRoguelikeBossPair(),
bossIds: createRoguelikeBossPair(variant, contentType, 1),
buffs: [],
contentType,
debuffs: [],
@@ -589,15 +597,27 @@ function applyRoguelikeChoice(
return {
...run,
...nextBase,
bossIds: createRandomRoguelikeBossPair(),
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1,
}
}
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
return chooseRunChoices(pool, 2)
function createRoguelikeBossPair(
variant: Iwt2RoguelikeVariant,
contentType: Iwt2RoguelikeContentType,
stage: number,
): 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[] {
+3
View File
@@ -1,11 +1,13 @@
export function ArenaBar({
className = '',
current,
label,
max,
shield = 0,
}: {
className?: string
current: number
label?: string
max: number
shield?: number
}) {
@@ -15,6 +17,7 @@ export function ArenaBar({
<div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
{label && <em>{label}</em>}
</div>
)
}
+1 -2
View File
@@ -46,9 +46,8 @@ export function PartyFrames({
<div>
<div className="iwt2-party-row-title">
<strong>{meta.name}</strong>
<small>{Math.ceil(member.health)}</small>
</div>
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
<ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)}
+2 -2
View File
@@ -194,7 +194,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.1,
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
maxHealth: 750,
radius: 29,
moveSpeed: 118,
meleeRange: 56,
@@ -224,7 +224,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
birdFlightCooldown: 10,
birdFlightWindup: 0.85,
birdFlightSpeed: 360,
birdHealth: 58,
birdHealth: 100,
birdRadius: 16,
birdContactDamage: 26,
birdStunSeconds: 0.75,
+57
View File
@@ -2,12 +2,47 @@ export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogu
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassEquipmentSlot =
| 'weapon'
| 'offhand'
| 'head'
| 'chest'
| 'hands'
| 'legs'
export type Iwt2ClassLayerMotion = {
offsetXScale?: number
offsetYScale?: number
rotation?: number
}
export type Iwt2ClassWeaponLayer = {
id: string
slot: Iwt2ClassEquipmentSlot
url: string
drawOrder: 'behindBody' | 'front'
heightScale: number
offsetXScale: number
offsetYScale: number
originX?: number
originY?: number
attackMotion?: Iwt2ClassLayerMotion
tierAccentEligible?: boolean
}
export type Iwt2ClassLayeredArenaSprite = {
bodyUrl: string
bodyHeightScale?: number
weaponLayers: Iwt2ClassWeaponLayer[]
}
export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId
name: string
role: Iwt2PlayerClassRole
icon: string
arenaSpriteUrl: string
arenaLayeredSprite?: Iwt2ClassLayeredArenaSprite
uiIconUrl: string
color: string
accentColor: string
@@ -118,6 +153,28 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
role: 'damage',
icon: '⚔',
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
arenaLayeredSprite: {
bodyUrl: '/iwt2/classes/arena/layers/warrior-body.png',
weaponLayers: [
{
id: 'axe',
slot: 'weapon',
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
drawOrder: 'front',
heightScale: 1.72,
offsetXScale: 0.38,
offsetYScale: -0.88,
originX: 0.18,
originY: 0.5,
attackMotion: {
offsetXScale: 0.22,
offsetYScale: -0.18,
rotation: 0.54,
},
tierAccentEligible: true,
},
],
},
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
color: '#ff7675',
accentColor: '#ffd0d0',
@@ -0,0 +1,34 @@
import { IWT2_PARTY_ORDER } from './classes'
import {
IWT2_GEAR_SLOTS,
type Iwt2GearLevel,
type Iwt2GearProgress,
} from './gear'
export type Iwt2PvpGearNormalizationConfig = {
enabled: boolean
gearLevel: Iwt2GearLevel
}
export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = {
enabled: true,
gearLevel: 5,
}
export function createIwt2PvpNormalizedGearProgress(
config = IWT2_PVP_GEAR_NORMALIZATION,
): Iwt2GearProgress | undefined {
if (!config.enabled) return undefined
return Object.fromEntries(
IWT2_PARTY_ORDER.map((classId) => [
classId,
{
slots: Object.fromEntries(
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
),
infusionAbilityId: null,
},
]),
) as Iwt2GearProgress
}
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
import type { Iwt2PartyEntityState } from '../sim'
import type { Iwt2ClassWeaponLayer } from '../content/classes'
export type PartyAttackPulse = {
startedAt: number
}
export type PartyRenderMotion = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
tint?: number
}
export type PartyWeaponLayerMotion = {
offsetXScale: number
offsetYScale: number
rotation: number
}
const ATTACK_DURATION_SECONDS = 0.22
const RANGED_ATTACK_DURATION_SECONDS = 0.16
export function partyRenderMotion(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
): PartyRenderMotion {
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: PartyRenderMotion = {
x: 0,
y: 0,
rotation: 0,
scaleX: 1,
scaleY: 1,
}
if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 }
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 }
}
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
if (attackProgress < 1) {
const strike = Math.sin(attackProgress * Math.PI)
const ranged = entity.projectileSpeed > 0
return {
...motion,
x: facingX * (ranged ? -3 : 4) * strike,
y: -1.5 * strike,
rotation: facingX * (ranged ? -0.05 : 0.09) * strike,
scaleX: 1 + (ranged ? 0.012 : 0.035) * strike,
scaleY: 1 - (ranged ? 0.006 : 0.018) * strike,
tint: ranged ? 0xdff3ff : 0xffe1a6,
}
}
if (entity.castSecondsRemaining > 0) {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
return {
...motion,
y: -1 - pulse * 1.5,
scaleX: 1 + pulse * 0.012,
scaleY: 1 + pulse * 0.012,
tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff,
}
}
if (speed > 10) {
const stride = Math.sin(timeSeconds * Math.PI * 9)
const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
return {
...motion,
y: -lift,
rotation: facingX * 0.025 * Math.min(1, speed / 190),
scaleY: 1 - Math.abs(stride) * 0.01,
}
}
const idle = Math.sin(timeSeconds * Math.PI * 2.2)
return {
...motion,
y: -Math.max(0, idle) * 0.9,
scaleY: 1 + Math.max(0, idle) * 0.008,
}
}
export function isPartyAttackPulseActive(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse: PartyAttackPulse,
): boolean {
return attackProgressFor(entity, timeSeconds, attackPulse) < 1
}
export function partyWeaponLayerMotion(
entity: Iwt2PartyEntityState,
layer: Iwt2ClassWeaponLayer,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
): PartyWeaponLayerMotion {
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
const strike = Math.sin(progress * Math.PI)
const attackMotion = layer.attackMotion
return {
offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike,
offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike,
rotation: (attackMotion?.rotation ?? 0) * strike,
}
}
function attackProgressFor(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse: PartyAttackPulse,
): number {
const duration = entity.projectileSpeed > 0 ? RANGED_ATTACK_DURATION_SECONDS : ATTACK_DURATION_SECONDS
return Math.max(0, Math.min(1, (timeSeconds - attackPulse.startedAt) / duration))
}
@@ -3,6 +3,12 @@ import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
import { bossRenderMotion } from '../bossAnimation'
import {
isPartyAttackPulseActive,
partyRenderMotion,
partyWeaponLayerMotion,
type PartyAttackPulse,
} from '../partyAnimation'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
@@ -27,6 +33,11 @@ type FloatingCombatTextAnchor = {
position: Iwt2Vec2
radius: number
}
type PartyLayerSprites = {
body: Phaser.GameObjects.Image
weapons: Map<string, Phaser.GameObjects.Image>
accents: Map<string, Phaser.GameObjects.Image>
}
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
@@ -37,10 +48,14 @@ export class BulldromeArenaScene extends Phaser.Scene {
private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private partySprites = new Map<string, Phaser.GameObjects.Image>()
private partyLayerSprites = new Map<string, PartyLayerSprites>()
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
private partyAttackPulses = new Map<string, PartyAttackPulse>()
private livePartyEffectIds = new Set<string>()
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private lastPartyAnimationEventId = 0
private lastFloatingEventId = 0
private lastStateTime = 0
private lastHudPublish = 0
@@ -60,6 +75,12 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
if (metadata.arenaLayeredSprite) {
this.load.image(classArenaBodySpriteKey(metadata.id), metadata.arenaLayeredSprite.bodyUrl)
for (const layer of metadata.arenaLayeredSprite.weaponLayers) {
this.load.image(classArenaLayerSpriteKey(metadata.id, layer.id), layer.url)
}
}
}
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
@@ -83,6 +104,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state)
this.updatePartyAnimations(state)
this.drawEntities(state)
this.drawProjectiles(state)
this.drawFloatingCombatTexts(state)
@@ -160,9 +182,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
}
if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
const metadata = IWT2_CLASS_METADATA[entity.classId]
if (metadata.arenaLayeredSprite && this.textures.exists(classArenaBodySpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha)
this.drawLayeredPartySprite(entity, alpha, state.time)
} else if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha, state.time)
} else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
@@ -187,6 +213,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
}
for (const [id, sprites] of this.partyLayerSprites) {
if (!livePartyIds.has(id)) {
sprites.body.destroy()
for (const sprite of sprites.weapons.values()) sprite.destroy()
for (const sprite of sprites.accents.values()) sprite.destroy()
this.partyLayerSprites.delete(id)
}
}
for (const [id, label] of this.labels) {
if (!liveLabelIds.has(id)) {
label.destroy()
@@ -260,23 +295,203 @@ export class BulldromeArenaScene extends Phaser.Scene {
.setAlpha(alpha)
}
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number) {
private drawLayeredPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const metadata = IWT2_CLASS_METADATA[entity.classId].arenaLayeredSprite
if (!metadata) return
let sprites = this.partyLayerSprites.get(entity.id)
if (!sprites) {
sprites = {
body: this.add.image(entity.position.x, entity.position.y, classArenaBodySpriteKey(entity.classId)).setOrigin(0.5, 0.66).setDepth(40),
weapons: new Map<string, Phaser.GameObjects.Image>(),
accents: new Map<string, Phaser.GameObjects.Image>(),
}
this.partyLayerSprites.set(entity.id, sprites)
}
const fallback = this.partySprites.get(entity.id)
if (fallback) {
fallback.destroy()
this.partySprites.delete(entity.id)
}
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
applyMotionTint(sprites.body, motion.tint)
sprites.body
.setTexture(classArenaBodySpriteKey(entity.classId))
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
const liveLayerIds = new Set<string>()
for (const layer of metadata.weaponLayers) {
liveLayerIds.add(layer.id)
let sprite = sprites.weapons.get(layer.id)
const key = classArenaLayerSpriteKey(entity.classId, layer.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.weapons.set(layer.id, sprite)
}
const flipX = entity.facing.x < -0.05
const facingSign = flipX ? -1 : 1
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
const layerHeight = entity.radius * layer.heightScale
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
const displayWidth = layerHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX
const displayHeight = layerHeight * motion.scaleY
const rotation = motion.rotation + facingSign * layerMotion.rotation
this.drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId: layer.id,
positionX,
positionY,
rotation,
sprites,
})
applyMotionTint(sprite, motion.tint)
sprite
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth, displayHeight)
.setRotation(rotation)
.setFlipX(flipX)
.setAlpha(alpha)
}
for (const [id, sprite] of sprites.weapons) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.weapons.delete(id)
}
}
for (const [id, sprite] of sprites.accents) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.accents.delete(id)
}
}
}
private drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId,
positionX,
positionY,
rotation,
sprites,
}: {
alpha: number
displayHeight: number
displayWidth: number
entity: Iwt2PartyEntityState
flipX: boolean
key: string
layer: NonNullable<(typeof IWT2_CLASS_METADATA)[Iwt2PlayerClassId]['arenaLayeredSprite']>['weaponLayers'][number]
layerId: string
positionX: number
positionY: number
rotation: number
sprites: PartyLayerSprites
}) {
const tierColor = gearTierAccentColor(gearLayerLevel(entity, layer.slot))
let accent = sprites.accents.get(layerId)
if (!layer.tierAccentEligible || !tierColor) {
if (accent) accent.setVisible(false)
return
}
if (!accent) {
accent = this.add.image(positionX, positionY, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.accents.set(layerId, accent)
}
accent
.setVisible(true)
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
.setRotation(rotation)
.setFlipX(flipX)
.setTint(tierColor)
.setAlpha(alpha * 0.82)
}
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const key = classArenaSpriteKey(entity.classId)
let sprite = this.partySprites.get(entity.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
this.partySprites.set(entity.id, sprite)
}
const layered = this.partyLayerSprites.get(entity.id)
if (layered) {
layered.body.destroy()
for (const layerSprite of layered.weapons.values()) layerSprite.destroy()
for (const layerSprite of layered.accents.values()) layerSprite.destroy()
this.partyLayerSprites.delete(entity.id)
}
const displayHeight = entity.radius * 4.55
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite
.setTexture(key)
.setPosition(entity.position.x, entity.position.y + entity.radius * 0.18)
.setDisplaySize(displayHeight * (sprite.width / Math.max(1, sprite.height)), displayHeight)
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
displayHeight * motion.scaleY,
)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
}
private updatePartyAnimations(state: Iwt2ArenaState) {
if (state.time < this.lastStateTime) {
this.lastPartyAnimationEventId = 0
this.partyAttackPulses.clear()
}
this.livePartyEffectIds.clear()
for (const member of state.party) this.livePartyEffectIds.add(member.id)
for (const event of state.events) {
if (event.id <= this.lastPartyAnimationEventId) continue
if (event.type === 'partyAttack' && this.livePartyEffectIds.has(event.sourceId)) {
this.partyAttackPulses.set(event.sourceId, { startedAt: event.time })
}
this.lastPartyAnimationEventId = Math.max(this.lastPartyAnimationEventId, event.id)
}
for (const member of state.party) {
const attackPulse = this.partyAttackPulses.get(member.id)
if (attackPulse && !isPartyAttackPulseActive(member, state.time, attackPulse)) {
this.partyAttackPulses.delete(member.id)
}
}
for (const id of this.partyAttackPulses.keys()) {
if (!this.livePartyEffectIds.has(id)) this.partyAttackPulses.delete(id)
}
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
@@ -432,6 +647,30 @@ function entityIcon(entity: DrawableEntity): string {
return IWT2_CLASS_METADATA[entity.classId].icon
}
function applyMotionTint(sprite: Phaser.GameObjects.Image, tint?: number) {
if (tint) {
sprite.setTint(tint)
} else {
sprite.clearTint()
}
}
function gearLayerLevel(entity: Iwt2PartyEntityState, slot: string): number {
if (slot === 'weapon') return entity.gearLevels?.weapon ?? 0
if (slot === 'head') return entity.gearLevels?.helmet ?? 0
if (slot === 'chest') return entity.gearLevels?.chest ?? 0
if (slot === 'legs') return entity.gearLevels?.legs ?? 0
return 0
}
function gearTierAccentColor(level: number): number | undefined {
if (level <= 0) return undefined
if (level <= 2) return 0x55e887
if (level === 3) return 0x58a8ff
if (level === 4) return 0xc56cf0
return 0xffd25f
}
function bossSpriteKey(bossId: Iwt2BossId): string {
return `iwt2-boss-${bossId}`
}
@@ -440,6 +679,14 @@ function classArenaSpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}`
}
function classArenaBodySpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}-body`
}
function classArenaLayerSpriteKey(classId: Iwt2PlayerClassId, layerId: string): string {
return `iwt2-class-${classId}-${layerId}`
}
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
}
+69 -31
View File
@@ -12,7 +12,7 @@ import {
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim'
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
@@ -45,6 +45,7 @@ import {
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
} from '../content/roguelike'
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'requeue' | 'menu'
@@ -64,6 +65,10 @@ const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
type BossArenaScreenProps = {
bossId: Iwt2BossId
@@ -72,6 +77,7 @@ type BossArenaScreenProps = {
modeLabel?: string
save: Iwt2Save
onBack: () => void
onMainMenu?: () => void
onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
@@ -84,10 +90,15 @@ type BossArenaScreenProps = {
}
}
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp'
const pvpNormalizedGearProgress = useMemo(() => createIwt2PvpNormalizedGearProgress(), [])
const activeGearProgress = pvpRoguelike
? pvpNormalizedGearProgress
: roguelikeRun?.variant !== 'pvp'
? save.gearProgress
: undefined
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
@@ -102,6 +113,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
const arenaBounds = useMemo(() => createTopScreenArenaBounds(), [])
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
bossId,
bossIds,
@@ -109,16 +121,20 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedDamageScale,
roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined,
activeGearProgress,
arenaBounds,
))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike
? createInitialIwt2ArenaState(
? createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
EMPTY_ROGUELIKE_BUFFS,
createPressureState(roguelikeStage, roguelikeContentType),
activeGearProgress,
arenaBounds,
)
: null
))
@@ -181,15 +197,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedDamageScale,
roguelikeBuffs,
pressureState,
pveGearActive ? save.gearProgress : undefined,
activeGearProgress,
arenaBounds,
)
const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState(
? createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
EMPTY_ROGUELIKE_BUFFS,
pressureState,
activeGearProgress,
arenaBounds,
)
: null
recordedKillIdsRef.current = new Set()
@@ -204,21 +224,27 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
setPetAwards([])
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
statusRef.current = nextStatus
setStatus(nextStatus)
}, [])
const resumeArena = useCallback(() => {
statusRef.current = 'playing'
setStatus('playing')
}, [])
const abilities = useMemo(
() => {
const baseAbilities = abilitiesForHealer(
save.character.healerStyle,
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
activeGearProgress?.healer.infusionAbilityId ?? null,
)
const gearAbilities = pveGearActive
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
const gearAbilities = activeGearProgress
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
: baseAbilities
return applyRoguelikeModifiers(
gearAbilities,
@@ -226,7 +252,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
roguelikeRun?.debuffs ?? [],
)
},
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
[activeGearProgress, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
@@ -268,11 +294,16 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (overlayAction === 'menu') {
if (statusRef.current === 'paused') {
const returnToMainMenu = onMainMenu ?? onBack
returnToMainMenu()
return
}
onBack()
return
}
if (statusRef.current === 'paused') {
setStatus('playing')
resumeArena()
return
}
if (statusRef.current === 'victory' && roguelikeRun) {
@@ -280,10 +311,10 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
resetArena()
}, [onBack, onPvpRequeue, resetArena, roguelikeRun])
}, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) {
if (action.startsWith('navigate')) {
moveOverlaySelection(action)
return
@@ -293,10 +324,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
if (statusRef.current === 'paused') resumeArena()
return
}
}
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') resumeArena()
return
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
@@ -320,16 +356,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return
}
if (action.startsWith('targetParty')) {
if (statusRef.current !== 'playing') return
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
return
}
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
@@ -438,12 +470,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
const pauseCopy = roguelikeRun?.variant === 'pvp'
? undefined
: 'Combat is stopped. Resume the fight or leave the current run.'
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
? 'Leave'
: roguelikeRun
? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}`
: 'Combat is stopped. Continue the fight or return to the main menu.'
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
@@ -508,23 +535,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
{pauseCopy && <p>{pauseCopy}</p>}
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Resume
Continue
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
{pauseLeaveLabel}
Main Menu
</button>
</div>
</div>
@@ -696,6 +721,7 @@ function createArenaState(
buffs: Iwt2RoguelikeSelfBuffId[],
roguelikePressure: ReturnType<typeof createPressureState>,
gearProgress?: Iwt2Save['gearProgress'],
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
const baseState = createInitialIwt2ArenaState(
bossId,
@@ -703,6 +729,7 @@ function createArenaState(
bossHealthScale,
partyDamageTakenScale,
roguelikePressure,
bounds,
)
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
@@ -718,6 +745,17 @@ function createArenaState(
}
}
function createTopScreenArenaBounds(): Iwt2ArenaBounds {
if (typeof window === 'undefined') return { width: 960, height: 540 }
const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH
&& window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT
const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH
return {
width: Math.max(320, Math.round(window.innerWidth - railWidth)),
height: Math.max(240, Math.round(window.innerHeight)),
}
}
function createPressureState(
stage: number | undefined,
contentType: Iwt2RoguelikeContentType | undefined,
+131 -60
View File
@@ -54,6 +54,7 @@ import {
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
type Iwt2GearStatId,
type Iwt2GearSlotId,
} from '../content/gear'
import {
@@ -1255,7 +1256,7 @@ export function Iwt2RoguelikeUpgradeScreen({
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
return
}
if (action === 'back' || action === 'pause') return
if (action === 'back') onBack()
})
return (
@@ -1546,33 +1547,10 @@ export function Iwt2HunterProfileScreen({
function moveSelection(action: InputAction) {
if (!action.startsWith('navigate') || navEntries.length === 0) return
setSelectedIndex((current) => {
const bounded = Math.min(current, navEntries.length - 1)
const active = navEntries[bounded]
if (!active) return 0
const candidates = navEntries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
})
const nextIndex = moveHunterProfileSelection(navEntries, selectedIndex, action)
const nextEntry = navEntries[nextIndex]
setSelectedIndex(nextIndex)
if (nextEntry?.kind === 'item') setFocusedItemKey(nextEntry.itemKey)
}
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
@@ -1862,6 +1840,7 @@ function Iwt2ProfileNameEditor({
<label>
<span>Profile Name</span>
<input
data-controller-nav="skip"
maxLength={IWT2_NAME_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
value={draft}
@@ -1900,38 +1879,42 @@ export function Iwt2CloudSaveScreen({
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
const [message, setMessage] = useState('')
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
setSyncingOnlineSave(true)
setMessage('Checking online save...')
try {
const result = await loadIwt2OnlineSave()
if (isCancelled()) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
} catch (reason) {
if (isCancelled()) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
} finally {
if (!isCancelled()) setSyncingOnlineSave(false)
}
}, [])
useEffect(() => {
let cancelled = false
if (!onlineBackupsAvailable) {
setOnlineSave(null)
setMessage('Online save unavailable in offline mode.')
return () => {
cancelled = true
}
}
setSyncingOnlineSave(true)
setMessage('Checking online save...')
loadIwt2OnlineSave()
.then((result) => {
if (cancelled) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
})
.catch((reason) => {
if (cancelled) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
})
.finally(() => {
if (!cancelled) setSyncingOnlineSave(false)
})
const timer = window.setTimeout(() => {
void checkOnlineSave(() => cancelled)
}, 0)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [onlineBackupsAvailable])
}, [checkOnlineSave, onlineBackupsAvailable])
const useLocalSave = useCallback(async () => {
const handleUseLocalSave = useCallback(async () => {
if (!onlineBackupsAvailable) {
setMessage('Using local save. Sign in online to update the server copy.')
return
@@ -1949,13 +1932,13 @@ export function Iwt2CloudSaveScreen({
}
}, [onlineBackupsAvailable, save])
const useOnlineSave = useCallback(() => {
if (!onlineSave) return
const handleUseOnlineSave = useCallback(() => {
if (!onlineBackupsAvailable || !onlineSave) return
onSaveUpdated(onlineSave)
setMessage('Local save now uses online progress.')
}, [onlineSave, onSaveUpdated])
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
const useNewSave = useCallback(() => {
const handleUseNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
@@ -1968,7 +1951,7 @@ export function Iwt2CloudSaveScreen({
value: onlineBackupsAvailable ? 'Upload' : 'Current',
disabled: syncingOnlineSave,
onConfirm: () => {
void useLocalSave()
void handleUseLocalSave()
},
},
{
@@ -1976,30 +1959,30 @@ export function Iwt2CloudSaveScreen({
label: 'Use Online Save',
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
value: onlineSave ? 'Download' : 'Empty',
disabled: syncingOnlineSave || !onlineSave,
onConfirm: useOnlineSave,
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
onConfirm: handleUseOnlineSave,
},
{
key: 'new-save',
label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New',
onConfirm: useNewSave,
onConfirm: handleUseNewSave,
},
], onBack), [
handleUseLocalSave,
handleUseNewSave,
handleUseOnlineSave,
onlineBackupsAvailable,
onlineSave,
onBack,
save,
syncingOnlineSave,
useLocalSave,
useNewSave,
useOnlineSave,
])
return (
<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} />
</Iwt2ScreenShell>
)
@@ -2021,6 +2004,9 @@ export function Iwt2GearUpgradeScreen({
const classProgress = save.gearProgress[selectedClassId]
const selectedSlot = classProgress.slots[selectedSlotId]
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
const nextLevel = Math.min(5, selectedSlot.level + 1)
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
const selectedClassName = gearClassDisplayName(selectedClassId, save)
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
@@ -2110,6 +2096,16 @@ export function Iwt2GearUpgradeScreen({
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
<div className="iwt2-gear-layout">
<section className="iwt2-gear-column">
<button
className={`back-button iwt2-gear-back ${activeEntry?.kind === 'back' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={activeEntry?.kind === 'back' ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'back')}
type="button"
>
Back
</button>
<div className="iwt2-gear-class-list">
{IWT2_PARTY_ORDER.map((classId) => {
const metadata = IWT2_CLASS_METADATA[classId]
@@ -2186,6 +2182,16 @@ export function Iwt2GearUpgradeScreen({
<strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong>
<small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small>
</p>
<div className="iwt2-gear-bonus-grid">
<span>
<strong>Current bonus</strong>
<small>{selectedBonus.text}</small>
</span>
<span>
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
</span>
</div>
<div className="iwt2-gear-cost-list">
{upgradeCosts.length === 0 ? (
<span>Max rank reached.</span>
@@ -2245,6 +2251,38 @@ function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string {
return role === 'damage' ? 'Damage' : 'Tank'
}
function gearBonusSummary(
statId: Iwt2GearStatId,
level: number,
classId: Iwt2PlayerClassId,
): { label: string, text: string, value: string } {
const bonus = Math.max(0, level)
if (statId === 'maxHealth') return gearBonus('Max health', `+${bonus * 4}%`)
if (statId === 'moveSpeed') return gearBonus('Move speed', `+${formatPercent(bonus * 2.5)}%`)
if (statId === 'damage') return gearBonus('Attack damage', `+${bonus * 4}%`)
if (statId === 'attackCooldown') {
const label = classId === 'healer' ? 'Ability cooldown' : 'Attack cooldown'
return gearBonus(label, `-${bonus * 3}%`)
}
if (statId === 'projectileSpeed') return gearBonus('Projectile speed', `+${bonus * 3}%`)
if (statId === 'hazardDamageTaken') return gearBonus('Hazard damage taken', `-${bonus * 3}%`)
if (statId === 'stunResist') return gearBonus('Stun/knockdown duration', `-${bonus * 8}%`)
if (statId === 'healingPower') return gearBonus('Ability healing', `+${bonus * 4}%`)
return gearBonus('Bonus', '+0%')
}
function gearBonus(label: string, value: string): { label: string, text: string, value: string } {
return { label, text: `${label} ${value}`, value }
}
function infusionAnchorText(level: number): string {
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
}
function formatPercent(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function clampToEnabled(actions: Iwt2NavAction[], index: number) {
if (actions.length === 0) return 0
const bounded = Math.min(Math.max(0, index), actions.length - 1)
@@ -2326,6 +2364,39 @@ function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
return entries[Math.min(index, entries.length - 1)]
}
function moveHunterProfileSelection(
entries: Iwt2HunterProfileNavEntry[],
current: number,
action: InputAction,
) {
if (!action.startsWith('navigate') || entries.length === 0) return current
const bounded = Math.min(current, entries.length - 1)
const active = entries[bounded]
if (!active) return 0
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index !== bounded)
.filter(({ entry }) => {
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.row < active.row
return entry.row > active.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
}
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
+33 -10
View File
@@ -9,6 +9,7 @@ import type {
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2HostileAddState,
Iwt2ArenaBounds,
Iwt2PartyAiRole,
Iwt2PartyEntityId,
Iwt2PartyEntityState,
@@ -66,14 +67,15 @@ export function createInitialIwt2ArenaState(
bossHealthScale = 1,
partyDamageTakenScale = 1,
roguelikePressure?: Iwt2RoguelikePressureState,
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale, bounds))
return {
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
bounds,
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)),
projectiles: [],
hostileAdds: [],
hazards: [],
@@ -95,9 +97,9 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
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 position = initialBossPosition(index)
const position = scaleArenaPoint(initialBossPosition(index), bounds)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return {
id: bossId,
@@ -123,9 +125,9 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
chargeHitEntityIds: [],
slamApplied: false,
wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds),
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 },
fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds),
birdWaveThresholdsTriggered: [],
mechanicLanes: [],
mechanicCircles: [],
@@ -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 {
if (bossId === 'bulldrome') return 2
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 safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
return {
@@ -332,7 +341,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
kind: 'party',
classId: initial.classId,
aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y },
position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds),
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
@@ -355,7 +364,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
attackReady: false,
status: createEmptyStatus(),
hotEffects: [],
preferredOffset: { ...initial.preferredOffset },
preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds),
decisionSecondsRemaining: initial.decisionOffset,
}
}
@@ -846,6 +855,13 @@ function applyPartyAttacks(
damage: member.attackDamage,
remainingSeconds: 1.2,
})
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
projectileId += 1
return {
...member,
@@ -876,6 +892,13 @@ function applyPartyAttacks(
targetId: target.id,
value: damage,
})
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
if (defeated) {
events.push({
id: 0,
+3 -1
View File
@@ -7,6 +7,7 @@ import {
import type {
Iwt2ArenaIndicator,
Iwt2ArenaInput,
Iwt2ArenaBounds,
Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState,
Iwt2HostileAddState,
@@ -61,8 +62,9 @@ export function createInitialIwt2ArenaState(
bossHealthScale?: number,
partyDamageTakenScale?: number,
roguelikePressure?: Iwt2RoguelikePressureState,
bounds?: Iwt2ArenaBounds,
): 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 {
+10 -1
View File
@@ -43,7 +43,16 @@ function applyMemberGearStats(
gearProgress: Iwt2GearProgress,
): Iwt2PartyEntityState {
const classProgress = gearProgress[member.classId]
let next = { ...member }
let next: Iwt2PartyEntityState = {
...member,
gearLevels: {
weapon: classProgress.slots.weapon.level,
helmet: classProgress.slots.helmet.level,
chest: classProgress.slots.chest.level,
legs: classProgress.slots.legs.level,
feet: classProgress.slots.feet.level,
},
}
for (const slotId of IWT2_GEAR_SLOTS) {
const level = classProgress.slots[slotId].level
if (level <= 0) continue
+4 -7
View File
@@ -9,18 +9,15 @@ import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE = 1.25
const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale(
_stage: number,
stage: number,
contentType: Iwt2RoguelikePressureContentType,
): number {
const base = contentType === 'dungeon'
? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE
: ROGUELIKE_RAID_DAMAGE_BASE_SCALE
return base
void stage
void contentType
return 1
}
export function createRoguelikePressureState(
+3
View File
@@ -1,5 +1,6 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
import type { Iwt2GearLevel, Iwt2GearSlotId } from '../content/gear'
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
export type Iwt2Vec2 = {
@@ -70,6 +71,7 @@ export type Iwt2PartyEntityState = {
mana: number
maxMana: number
damageDone: number
gearLevels?: Partial<Record<Iwt2GearSlotId, Iwt2GearLevel>>
attackCooldownRemaining: number
castSecondsRemaining: number
attackReady: boolean
@@ -283,6 +285,7 @@ export type Iwt2GroundHazardState = {
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'healerSpellCast'
| 'partyAttack'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB