Compare commits

...
9 Commits
Author SHA1 Message Date
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
Warren H 62dc51b42a Android build v1.1.26 2026-07-05 23:21:37 -04:00
Warren H defbf0ce10 Android build v1.1.25 2026-07-05 22:51:34 -04:00
45 changed files with 1133 additions and 183 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 103 versionCode 112
versionName "1.1.24" versionName "1.1.31"
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.
+6
View File
@@ -96,6 +96,12 @@ curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$REL
-F "attachment=@$APK" -F "attachment=@$APK"
``` ```
If the CLI release fails after creating the Gitea release but before uploading the APK asset, retry only the release upload:
```sh
npm run release:cli -- --release-only 1.1.26
```
## Step 5: Update TrueNAS ## Step 5: Update TrueNAS
```sh ```sh
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 os
import queue import queue
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -153,6 +154,48 @@ def gitea_request(
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
data = exc.read() data = exc.read()
return exc.code, data return exc.code, data
except urllib.error.URLError as exc:
raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc
def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]:
curl = shutil.which("curl")
if curl is None:
raise ReleaseError("curl is required for Gitea asset uploads")
url = config.gitea_url.rstrip("/") + path
proc = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"2",
"--request",
"POST",
url,
"--header",
f"Authorization: token {config.token}",
"--form",
f"attachment=@{apk_path}",
"--write-out",
"\n%{http_code}",
],
check=False,
capture_output=True,
)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
try:
body, status_text = proc.stdout.rsplit(b"\n", 1)
return int(status_text), body
except ValueError as exc:
output = proc.stdout.decode("utf-8", "replace")
raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc
def parse_json_response(status: int, data: bytes, action: str) -> dict: def parse_json_response(status: int, data: bytes, action: str) -> dict:
@@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N
raise ReleaseError(f"APK not found for release upload: {apk_path}") raise ReleaseError(f"APK not found for release upload: {apk_path}")
release_id = find_or_create_release(config, log) release_id = find_or_create_release(config, log)
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = header + apk_path.read_bytes() + footer
asset_name = urllib.parse.quote(apk_path.name) asset_name = urllib.parse.quote(apk_path.name)
path = ( path = (
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}" f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
f"/releases/{release_id}/assets?name={asset_name}" f"/releases/{release_id}/assets?name={asset_name}"
) )
status, data = gitea_request( status, data = gitea_upload_asset(config, path, apk_path)
config,
"POST",
path,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
if status == 409: if status == 409:
raise ReleaseError(f"Release asset already exists: {apk_path.name}") raise ReleaseError(f"Release asset already exists: {apk_path.name}")
parse_json_response(status, data, "Upload release asset") parse_json_response(status, data, "Upload release asset")
+97 -24
View File
@@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true", action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.", help="Skip the RESET confirmation when used with --reset-player-data.",
) )
parser.add_argument(
"--release-only",
metavar="VERSION",
help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data: if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data") parser.error("--yes-reset requires --reset-player-data")
if args.release_only and args.reset_player_data:
parser.error("--release-only cannot be combined with --reset-player-data")
return args return args
@@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) -
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace") detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict: def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary" url = GITEA_URL + path
body = ( curl = shutil.which("curl")
f"--{boundary}\r\n" if curl is None:
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n' raise SystemExit("curl is required for Gitea asset uploads")
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8") proc = subprocess.run(
body += apk.read_bytes() [
body += f"\r\n--{boundary}--\r\n".encode("utf-8") curl,
headers = { "--silent",
"Authorization": f"token {token}", "--show-error",
"Content-Type": f"multipart/form-data; boundary={boundary}", "--retry",
} "3",
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST") "--retry-all-errors",
"--retry-delay",
"2",
"--request",
"POST",
url,
"--header",
f"Authorization: token {token}",
"--form",
f"attachment=@{apk}",
"--write-out",
"\n%{http_code}",
],
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
detail = proc.stderr.strip() or proc.stdout.strip()
raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
try:
body, status_text = proc.stdout.rsplit("\n", 1)
status = int(status_text)
except ValueError as exc:
raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc
if status >= 400:
raise SystemExit(f"Gitea asset upload failed ({status}): {body}")
try:
parsed = json.loads(body or "{}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc
if not isinstance(parsed, dict):
raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}")
return parsed
def find_or_create_release(version: str, token: str) -> int:
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
tag = f"v{version}"
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET")
try: try:
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8")) release = json.loads(resp.read().decode("utf-8"))
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
print(f"Gitea release exists: {tag} (id {release_id})")
return int(release_id)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if exc.code != 404:
detail = exc.read().decode("utf-8", "replace") detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json( release = request_json(
"POST", "POST",
repo_path + "/releases", repo_path + "/releases",
token, token,
{ {
"tag_name": f"v{version}", "tag_name": tag,
"target_commitish": BRANCH, "target_commitish": BRANCH,
"name": f"v{version}", "name": tag,
"body": f"I Want to Heal Android build v{version}", "body": f"I Want to Heal Android build v{version}",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
@@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None:
release_id = release.get("id") release_id = release.get("id")
if not release_id: if not release_id:
raise SystemExit(f"Gitea release missing id: {release}") raise SystemExit(f"Gitea release missing id: {release}")
print(f"Gitea release created: {tag} (id {release_id})")
return int(release_id)
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release_id = find_or_create_release(version, token)
asset_name = urllib.parse.quote(apk.name) asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk) upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}") print(f"Gitea release uploaded: v{version}")
@@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv) args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT) os.chdir(REPO_ROOT)
if args.release_only:
version = args.release_only.strip()
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
if not apk.exists():
raise SystemExit(f"APK not found: {apk}")
create_gitea_release(version, apk)
return 0
version = prompt_version() version = prompt_version()
apk = build_apk(version) apk = build_apk(version)
commit_and_push(version) commit_and_push(version)
+94 -15
View File
@@ -212,6 +212,8 @@
} }
.iwt2-arena-layout { .iwt2-arena-layout {
display: grid;
grid-template-columns: 172px minmax(0, 1fr);
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
@@ -221,6 +223,7 @@
.iwt2-arena-stage { .iwt2-arena-stage {
background: #090c10; background: #090c10;
border: 0; border: 0;
grid-column: 2;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -317,6 +320,22 @@
top: 0; top: 0;
} }
.iwt2-bar em {
color: #fff7df;
font-size: 0.56rem;
font-style: normal;
font-weight: 800;
left: 0;
line-height: 1;
pointer-events: none;
position: absolute;
right: 0;
text-align: center;
text-shadow: 0 1px 0 #050608;
top: 50%;
transform: translateY(-50%);
}
.iwt2-bar.boss span { .iwt2-bar.boss span {
background: #dc5162; background: #dc5162;
} }
@@ -337,17 +356,18 @@
.iwt2-party-list { .iwt2-party-list {
background: rgba(15, 19, 26, 0.88); background: rgba(15, 19, 26, 0.88);
border: 0; border: 0;
left: 28px; bottom: 0;
max-height: calc(100% - 112px); left: 0;
max-height: none;
outline: 0; outline: 0;
padding: 6px; padding: 6px;
position: absolute; position: fixed;
top: 28px; top: 0;
width: 172px; width: 172px;
z-index: 4; z-index: 4;
display: grid; display: grid;
gap: 8px; gap: 8px;
align-content: start; align-content: center;
overflow: auto; overflow: auto;
} }
@@ -1099,7 +1119,8 @@
.iwt2-gear-class, .iwt2-gear-class,
.iwt2-gear-slot, .iwt2-gear-slot,
.iwt2-infusion-row, .iwt2-infusion-row,
.iwt2-gear-upgrade-button { .iwt2-gear-upgrade-button,
.iwt2-gear-back {
background: #151922; background: #151922;
border: 2px solid #08090d; border: 2px solid #08090d;
color: var(--ink); color: var(--ink);
@@ -1177,7 +1198,8 @@
.iwt2-gear-class.game-selected, .iwt2-gear-class.game-selected,
.iwt2-gear-slot.game-selected, .iwt2-gear-slot.game-selected,
.iwt2-infusion-row.game-selected, .iwt2-infusion-row.game-selected,
.iwt2-gear-upgrade-button.game-selected { .iwt2-gear-upgrade-button.game-selected,
.iwt2-gear-back.game-selected {
outline-color: #e5b95f; outline-color: #e5b95f;
} }
@@ -1187,6 +1209,12 @@
text-align: center; text-align: center;
} }
.iwt2-gear-back {
min-height: 36px;
padding: 8px 10px;
text-align: center;
}
.iwt2-gear-upgrade-button:disabled, .iwt2-gear-upgrade-button:disabled,
.iwt2-infusion-row:disabled { .iwt2-infusion-row:disabled {
color: var(--muted); color: var(--muted);
@@ -1219,6 +1247,28 @@
padding: 6px 8px; padding: 6px 8px;
} }
.iwt2-gear-bonus-grid {
display: grid;
gap: 7px;
}
.iwt2-gear-bonus-grid span {
background: #0d1118;
border: 1px solid #29313d;
display: block;
padding: 7px 8px;
}
.iwt2-gear-bonus-grid strong {
color: #f2c96d;
font-size: 0.62rem;
}
.iwt2-gear-bonus-grid small {
color: #e5edf6;
font-size: 0.62rem;
}
.iwt2-gear-cost-list span.met { .iwt2-gear-cost-list span.met {
color: #9ef0bd; color: #9ef0bd;
} }
@@ -1379,6 +1429,10 @@
} }
@media (max-width: 1000px) and (max-height: 620px) { @media (max-width: 1000px) and (max-height: 620px) {
.iwt2-arena-layout {
grid-template-columns: 154px minmax(0, 1fr);
}
.game-shell.iwt2-shell { .game-shell.iwt2-shell {
padding: 6px 0; padding: 6px 0;
width: min(100%, calc(100% - 20px)); width: min(100%, calc(100% - 20px));
@@ -1618,11 +1672,29 @@
padding: 7px 9px; padding: 7px 9px;
} }
.iwt2-gear-back {
min-height: 30px;
padding: 5px 8px;
}
.iwt2-gear-detail { .iwt2-gear-detail {
gap: 6px; gap: 6px;
padding: 7px; padding: 7px;
} }
.iwt2-gear-bonus-grid {
gap: 5px;
}
.iwt2-gear-bonus-grid span {
padding: 5px 6px;
}
.iwt2-gear-bonus-grid strong,
.iwt2-gear-bonus-grid small {
font-size: 0.52rem;
}
.iwt2-infusion-row { .iwt2-infusion-row {
gap: 7px; gap: 7px;
grid-template-columns: 28px minmax(0, 1fr); grid-template-columns: 28px minmax(0, 1fr);
@@ -1700,20 +1772,22 @@
} }
.iwt2-party-list { .iwt2-party-list {
gap: 5px; align-content: center;
left: 10px; gap: 7px;
max-height: calc(100% - 84px); bottom: 0;
left: 0;
max-height: none;
overflow: hidden; overflow: hidden;
padding: 4px; padding: 4px;
top: 10px; top: 0;
width: 154px; width: 154px;
} }
.iwt2-party-row { .iwt2-party-row {
gap: 5px; gap: 5px;
grid-template-columns: 22px minmax(0, 1fr); grid-template-columns: 22px minmax(0, 1fr);
height: 62px; height: 70px;
min-height: 62px; min-height: 70px;
padding: 4px; padding: 4px;
} }
@@ -1747,8 +1821,12 @@
} }
.iwt2-party-row .iwt2-bar { .iwt2-party-row .iwt2-bar {
height: 7px; height: 12px;
margin-top: 2px; margin-top: 3px;
}
.iwt2-party-row .iwt2-bar.mana {
height: 8px;
} }
.iwt2-party-effects { .iwt2-party-effects {
@@ -1768,6 +1846,7 @@
.iwt2-ability-bar { .iwt2-ability-bar {
bottom: 8px; bottom: 8px;
display: none;
gap: 8px; gap: 8px;
grid-template-columns: repeat(6, 36px); grid-template-columns: repeat(6, 36px);
max-width: 256px; max-width: 256px;
+6 -4
View File
@@ -266,6 +266,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode = 'ability', roguelikeAbilityLabelMode = 'ability',
roguelikeEncounterPool, roguelikeEncounterPool,
onExit, onExit,
onMainMenu = onExit,
onProfileUpdated, onProfileUpdated,
}: { }: {
difficulty: Difficulty difficulty: Difficulty
@@ -279,6 +280,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
roguelikeEncounterPool?: DungeonEncounter[] roguelikeEncounterPool?: DungeonEncounter[]
onExit: () => void onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void onProfileUpdated: (profile: CharacterProfile) => void
}) { }) {
const staticEncounters = useMemo( const staticEncounters = useMemo(
@@ -1399,10 +1401,10 @@ export function CombatScreen({
<div> <div>
<p className="eyebrow">Game Paused</p> <p className="eyebrow">Game Paused</p>
<h2>{dungeon.name}</h2> <h2>{dungeon.name}</h2>
<p>Combat is stopped. Resume the fight or leave the current run.</p> <p>Combat is stopped. Continue the fight or return to the main menu.</p>
<button onClick={() => setPaused(false)} type="button">Resume</button> <button onClick={() => setPaused(false)} type="button">Continue</button>
<button className="secondary-result-button" onClick={onExit} type="button"> <button className="secondary-result-button" onClick={onMainMenu} type="button">
Leave {contentName} Main Menu
</button> </button>
</div> </div>
</div> </div>
+14 -5
View File
@@ -294,6 +294,7 @@ export function PvPRoguelikeScreen({
contentType, contentType,
encounterPool, encounterPool,
onExit, onExit,
onMainMenu = onExit,
onProfileUpdated, onProfileUpdated,
}: { }: {
profile: CharacterProfile profile: CharacterProfile
@@ -301,6 +302,7 @@ export function PvPRoguelikeScreen({
contentType: PvpContentType contentType: PvpContentType
encounterPool: DungeonEncounter[] encounterPool: DungeonEncounter[]
onExit: () => void onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void onProfileUpdated: (profile: CharacterProfile) => void
}) { }) {
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)! const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
@@ -1423,14 +1425,14 @@ export function PvPRoguelikeScreen({
if (!entry || pvpOverlayEntryDisabled(entry)) return if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit() if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false) else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit() else if (entry.kind === 'pauseLeave') onMainMenu()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null) else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null) else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices() else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
} }
useGameAction((action) => { useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') { if (status === 'queueing') {
if (action === 'back' || action === 'pause') onExit() if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry()) if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return return
@@ -1447,6 +1449,13 @@ export function PvPRoguelikeScreen({
if (action.startsWith('navigate')) movePvpOverlaySelection(action) if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return return
} }
if (status === 'round-countdown') {
if (action === 'back' || action === 'pause') {
setOverlaySelectedIndex(0)
setPaused(true)
}
return
}
if (status === 'upgrade-choice') { if (status === 'upgrade-choice') {
if (action === 'confirm') { if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry()) openPvpOverlayEntry(activePvpOverlayEntry())
@@ -1780,16 +1789,16 @@ export function PvPRoguelikeScreen({
onPointerDown={() => setPvpOverlayCursor('pauseResume')} onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button" type="button"
> >
Resume Continue
</button> </button>
<button <button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`} className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip" data-controller-nav="skip"
onClick={onExit} onClick={onMainMenu}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')} onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button" type="button"
> >
Leave Main Menu
</button> </button>
</div> </div>
</div> </div>
+18 -2
View File
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
).some(isVisible) ).some(isVisible)
} }
function isDedicatedNavigationAction(action: InputAction) {
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
}
function dispatchGameAction(action: InputAction, device: InputDevice) { function dispatchGameAction(action: InputAction, device: InputDevice) {
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, { window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
detail: { action, device }, detail: { action, device },
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) { if (
device === 'controller'
&& dedicatedNavAction
&& !keyboardInputRef.current
&& hasDedicatedGameNavigation()
) {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur() if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
dispatchGameAction(action, device) dispatchGameAction(action, device)
return return
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateRight', 'navigateRight',
'confirm', 'confirm',
'back', 'back',
'pause',
] satisfies InputAction[] ] satisfies InputAction[]
const directTargetActions = [ const directTargetActions = [
'targetParty1', 'targetParty1',
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft', 'navigateLeft',
'navigateRight', 'navigateRight',
] satisfies InputAction[] ] satisfies InputAction[]
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay) const dedicatedNavigationActive = hasDedicatedGameNavigation()
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)
+5
View File
@@ -860,6 +860,7 @@ function IWantToHeal1App({
onExit={() => { onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}} }}
onMainMenu={() => setScreen('menu')}
onProfileUpdated={setProfile} onProfileUpdated={setProfile}
/> />
</Suspense> </Suspense>
@@ -893,6 +894,10 @@ function IWantToHeal1App({
setRoguelikeVariant('pvp') setRoguelikeVariant('pvp')
setScreen('roguelike') setScreen('roguelike')
}} }}
onMainMenu={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setScreen('menu')
}}
onProfileUpdated={setProfile} onProfileUpdated={setProfile}
profile={profile} profile={profile}
/> />
+13 -5
View File
@@ -46,6 +46,11 @@ import {
type Iwt2RoguelikeSelfBuffId, type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant, type Iwt2RoguelikeVariant,
} from './content/roguelike' } from './content/roguelike'
import {
createIwt2PveRoguelikeBossPair,
createUniformIwt2RoguelikeBossPair,
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
} from './content/roguelikeBossProgression'
type Iwt2Screen = type Iwt2Screen =
| 'menu' | 'menu'
@@ -258,6 +263,7 @@ export function IWantToHeal2App({
}} }}
save={save} save={save}
onBack={() => setScreen('roguelike')} onBack={() => setScreen('roguelike')}
onMainMenu={() => setScreen('menu')}
onPvpRequeue={() => { onPvpRequeue={() => {
setScreen('roguelike') setScreen('roguelike')
startIwt2RoguelikeRun() startIwt2RoguelikeRun()
@@ -521,7 +527,7 @@ function createRoguelikeRun(
contentType: Iwt2RoguelikeContentType, contentType: Iwt2RoguelikeContentType,
): Iwt2RoguelikeRunState { ): Iwt2RoguelikeRunState {
return { return {
bossIds: createRandomRoguelikeBossPair(), bossIds: createRoguelikeBossPair(variant, 1),
buffs: [], buffs: [],
contentType, contentType,
debuffs: [], debuffs: [],
@@ -589,15 +595,17 @@ function applyRoguelikeChoice(
return { return {
...run, ...run,
...nextBase, ...nextBase,
bossIds: createRandomRoguelikeBossPair(), bossIds: createRoguelikeBossPair(run.variant, run.stage + 1),
...buildRoguelikeChoices(save, run.variant), ...buildRoguelikeChoices(save, run.variant),
stage: run.stage + 1, stage: run.stage + 1,
} }
} }
function createRandomRoguelikeBossPair(): Iwt2BossId[] { function createRoguelikeBossPair(variant: Iwt2RoguelikeVariant, stage: number): Iwt2BossId[] {
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[] if (variant === 'pve' && IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED) {
return chooseRunChoices(pool, 2) return createIwt2PveRoguelikeBossPair(stage)
}
return createUniformIwt2RoguelikeBossPair()
} }
function chooseRunChoices<T>(items: readonly T[], count: number): T[] { function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
+3
View File
@@ -1,11 +1,13 @@
export function ArenaBar({ export function ArenaBar({
className = '', className = '',
current, current,
label,
max, max,
shield = 0, shield = 0,
}: { }: {
className?: string className?: string
current: number current: number
label?: string
max: number max: number
shield?: number shield?: number
}) { }) {
@@ -15,6 +17,7 @@ export function ArenaBar({
<div className={`iwt2-bar ${className}`}> <div className={`iwt2-bar ${className}`}>
<span style={{ width: `${percent}%` }} /> <span style={{ width: `${percent}%` }} />
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />} {shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
{label && <em>{label}</em>}
</div> </div>
) )
} }
+1 -2
View File
@@ -46,9 +46,8 @@ export function PartyFrames({
<div> <div>
<div className="iwt2-party-row-title"> <div className="iwt2-party-row-title">
<strong>{meta.name}</strong> <strong>{meta.name}</strong>
<small>{Math.ceil(member.health)}</small>
</div> </div>
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} /> <ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
{member.maxMana > 0 && ( {member.maxMana > 0 && (
<ArenaBar className="mana" current={member.mana} max={member.maxMana} /> <ArenaBar className="mana" current={member.mana} max={member.maxMana} />
)} )}
+2 -2
View File
@@ -194,7 +194,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.1, spriteYOffsetScale: -0.1,
color: '#d66a35', color: '#d66a35',
accentColor: '#ffd166', accentColor: '#ffd166',
maxHealth: 400, maxHealth: 750,
radius: 29, radius: 29,
moveSpeed: 118, moveSpeed: 118,
meleeRange: 56, meleeRange: 56,
@@ -224,7 +224,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
birdFlightCooldown: 10, birdFlightCooldown: 10,
birdFlightWindup: 0.85, birdFlightWindup: 0.85,
birdFlightSpeed: 360, birdFlightSpeed: 360,
birdHealth: 58, birdHealth: 100,
birdRadius: 16, birdRadius: 16,
birdContactDamage: 26, birdContactDamage: 26,
birdStunSeconds: 0.75, 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 Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassEquipmentSlot =
| 'weapon'
| 'offhand'
| 'head'
| 'chest'
| 'hands'
| 'legs'
export type Iwt2ClassLayerMotion = {
offsetXScale?: number
offsetYScale?: number
rotation?: number
}
export type Iwt2ClassWeaponLayer = {
id: string
slot: Iwt2ClassEquipmentSlot
url: string
drawOrder: 'behindBody' | 'front'
heightScale: number
offsetXScale: number
offsetYScale: number
originX?: number
originY?: number
attackMotion?: Iwt2ClassLayerMotion
tierAccentEligible?: boolean
}
export type Iwt2ClassLayeredArenaSprite = {
bodyUrl: string
bodyHeightScale?: number
weaponLayers: Iwt2ClassWeaponLayer[]
}
export type Iwt2ClassMetadata = { export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId id: Iwt2PlayerClassId
name: string name: string
role: Iwt2PlayerClassRole role: Iwt2PlayerClassRole
icon: string icon: string
arenaSpriteUrl: string arenaSpriteUrl: string
arenaLayeredSprite?: Iwt2ClassLayeredArenaSprite
uiIconUrl: string uiIconUrl: string
color: string color: string
accentColor: string accentColor: string
@@ -118,6 +153,28 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
role: 'damage', role: 'damage',
icon: '⚔', icon: '⚔',
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png', arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
arenaLayeredSprite: {
bodyUrl: '/iwt2/classes/arena/layers/warrior-body.png',
weaponLayers: [
{
id: 'axe',
slot: 'weapon',
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
drawOrder: 'front',
heightScale: 1.72,
offsetXScale: 0.38,
offsetYScale: -0.88,
originX: 0.18,
originY: 0.5,
attackMotion: {
offsetXScale: 0.22,
offsetYScale: -0.18,
rotation: 0.54,
},
tierAccentEligible: true,
},
],
},
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png', uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
color: '#ff7675', color: '#ff7675',
accentColor: '#ffd0d0', accentColor: '#ffd0d0',
@@ -0,0 +1,160 @@
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
export const IWT2_PVE_ROGUELIKE_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[] {
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_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes' import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
import { bossRenderMotion } from '../bossAnimation' import { bossRenderMotion } from '../bossAnimation'
import {
isPartyAttackPulseActive,
partyRenderMotion,
partyWeaponLayerMotion,
type PartyAttackPulse,
} from '../partyAnimation'
import type { import type {
Iwt2ArenaEvent, Iwt2ArenaEvent,
Iwt2ArenaIndicator, Iwt2ArenaIndicator,
@@ -27,6 +33,11 @@ type FloatingCombatTextAnchor = {
position: Iwt2Vec2 position: Iwt2Vec2
radius: number radius: number
} }
type PartyLayerSprites = {
body: Phaser.GameObjects.Image
weapons: Map<string, Phaser.GameObjects.Image>
accents: Map<string, Phaser.GameObjects.Image>
}
export class BulldromeArenaScene extends Phaser.Scene { export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps private deps: SceneDeps
@@ -37,10 +48,14 @@ export class BulldromeArenaScene extends Phaser.Scene {
private labels = new Map<string, Phaser.GameObjects.Text>() private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>() private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private partySprites = new Map<string, Phaser.GameObjects.Image>() private partySprites = new Map<string, Phaser.GameObjects.Image>()
private partyLayerSprites = new Map<string, PartyLayerSprites>()
private projectileSprites = new Map<string, Phaser.GameObjects.Image>() private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
private partyAttackPulses = new Map<string, PartyAttackPulse>()
private livePartyEffectIds = new Set<string>()
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>() private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>() private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>() private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private lastPartyAnimationEventId = 0
private lastFloatingEventId = 0 private lastFloatingEventId = 0
private lastStateTime = 0 private lastStateTime = 0
private lastHudPublish = 0 private lastHudPublish = 0
@@ -60,6 +75,12 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
for (const metadata of Object.values(IWT2_CLASS_METADATA)) { for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl) this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
if (metadata.arenaLayeredSprite) {
this.load.image(classArenaBodySpriteKey(metadata.id), metadata.arenaLayeredSprite.bodyUrl)
for (const layer of metadata.arenaLayeredSprite.weaponLayers) {
this.load.image(classArenaLayerSpriteKey(metadata.id, layer.id), layer.url)
}
}
} }
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png') this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png') this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
@@ -83,6 +104,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state) this.drawArena(state)
this.drawTelegraphs(state) this.drawTelegraphs(state)
this.updatePartyAnimations(state)
this.drawEntities(state) this.drawEntities(state)
this.drawProjectiles(state) this.drawProjectiles(state)
this.drawFloatingCombatTexts(state) this.drawFloatingCombatTexts(state)
@@ -160,9 +182,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.lineStyle(4, 0xfff4a8, 0.95) graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8) graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
} }
if (this.textures.exists(classArenaSpriteKey(entity.classId))) { const metadata = IWT2_CLASS_METADATA[entity.classId]
if (metadata.arenaLayeredSprite && this.textures.exists(classArenaBodySpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha) drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha) this.drawLayeredPartySprite(entity, alpha, state.time)
} else if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha, state.time)
} else { } else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha) graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius) graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
@@ -187,6 +213,15 @@ export class BulldromeArenaScene extends Phaser.Scene {
} }
} }
for (const [id, sprites] of this.partyLayerSprites) {
if (!livePartyIds.has(id)) {
sprites.body.destroy()
for (const sprite of sprites.weapons.values()) sprite.destroy()
for (const sprite of sprites.accents.values()) sprite.destroy()
this.partyLayerSprites.delete(id)
}
}
for (const [id, label] of this.labels) { for (const [id, label] of this.labels) {
if (!liveLabelIds.has(id)) { if (!liveLabelIds.has(id)) {
label.destroy() label.destroy()
@@ -260,23 +295,203 @@ export class BulldromeArenaScene extends Phaser.Scene {
.setAlpha(alpha) .setAlpha(alpha)
} }
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number) { private drawLayeredPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const metadata = IWT2_CLASS_METADATA[entity.classId].arenaLayeredSprite
if (!metadata) return
let sprites = this.partyLayerSprites.get(entity.id)
if (!sprites) {
sprites = {
body: this.add.image(entity.position.x, entity.position.y, classArenaBodySpriteKey(entity.classId)).setOrigin(0.5, 0.66).setDepth(40),
weapons: new Map<string, Phaser.GameObjects.Image>(),
accents: new Map<string, Phaser.GameObjects.Image>(),
}
this.partyLayerSprites.set(entity.id, sprites)
}
const fallback = this.partySprites.get(entity.id)
if (fallback) {
fallback.destroy()
this.partySprites.delete(entity.id)
}
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
applyMotionTint(sprites.body, motion.tint)
sprites.body
.setTexture(classArenaBodySpriteKey(entity.classId))
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
const liveLayerIds = new Set<string>()
for (const layer of metadata.weaponLayers) {
liveLayerIds.add(layer.id)
let sprite = sprites.weapons.get(layer.id)
const key = classArenaLayerSpriteKey(entity.classId, layer.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.weapons.set(layer.id, sprite)
}
const flipX = entity.facing.x < -0.05
const facingSign = flipX ? -1 : 1
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
const layerHeight = entity.radius * layer.heightScale
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
const displayWidth = layerHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX
const displayHeight = layerHeight * motion.scaleY
const rotation = motion.rotation + facingSign * layerMotion.rotation
this.drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId: layer.id,
positionX,
positionY,
rotation,
sprites,
})
applyMotionTint(sprite, motion.tint)
sprite
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth, displayHeight)
.setRotation(rotation)
.setFlipX(flipX)
.setAlpha(alpha)
}
for (const [id, sprite] of sprites.weapons) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.weapons.delete(id)
}
}
for (const [id, sprite] of sprites.accents) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.accents.delete(id)
}
}
}
private drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId,
positionX,
positionY,
rotation,
sprites,
}: {
alpha: number
displayHeight: number
displayWidth: number
entity: Iwt2PartyEntityState
flipX: boolean
key: string
layer: NonNullable<(typeof IWT2_CLASS_METADATA)[Iwt2PlayerClassId]['arenaLayeredSprite']>['weaponLayers'][number]
layerId: string
positionX: number
positionY: number
rotation: number
sprites: PartyLayerSprites
}) {
const tierColor = gearTierAccentColor(gearLayerLevel(entity, layer.slot))
let accent = sprites.accents.get(layerId)
if (!layer.tierAccentEligible || !tierColor) {
if (accent) accent.setVisible(false)
return
}
if (!accent) {
accent = this.add.image(positionX, positionY, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.accents.set(layerId, accent)
}
accent
.setVisible(true)
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
.setRotation(rotation)
.setFlipX(flipX)
.setTint(tierColor)
.setAlpha(alpha * 0.82)
}
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const key = classArenaSpriteKey(entity.classId) const key = classArenaSpriteKey(entity.classId)
let sprite = this.partySprites.get(entity.id) let sprite = this.partySprites.get(entity.id)
if (!sprite) { if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40) sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
this.partySprites.set(entity.id, sprite) this.partySprites.set(entity.id, sprite)
} }
const layered = this.partyLayerSprites.get(entity.id)
if (layered) {
layered.body.destroy()
for (const layerSprite of layered.weapons.values()) layerSprite.destroy()
for (const layerSprite of layered.accents.values()) layerSprite.destroy()
this.partyLayerSprites.delete(entity.id)
}
const displayHeight = entity.radius * 4.55 const displayHeight = entity.radius * 4.55
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite sprite
.setTexture(key) .setTexture(key)
.setPosition(entity.position.x, entity.position.y + entity.radius * 0.18) .setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(displayHeight * (sprite.width / Math.max(1, sprite.height)), displayHeight) .setDisplaySize(
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
displayHeight * motion.scaleY,
)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05) .setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha) .setAlpha(alpha)
} }
private updatePartyAnimations(state: Iwt2ArenaState) {
if (state.time < this.lastStateTime) {
this.lastPartyAnimationEventId = 0
this.partyAttackPulses.clear()
}
this.livePartyEffectIds.clear()
for (const member of state.party) this.livePartyEffectIds.add(member.id)
for (const event of state.events) {
if (event.id <= this.lastPartyAnimationEventId) continue
if (event.type === 'partyAttack' && this.livePartyEffectIds.has(event.sourceId)) {
this.partyAttackPulses.set(event.sourceId, { startedAt: event.time })
}
this.lastPartyAnimationEventId = Math.max(this.lastPartyAnimationEventId, event.id)
}
for (const member of state.party) {
const attackPulse = this.partyAttackPulses.get(member.id)
if (attackPulse && !isPartyAttackPulseActive(member, state.time, attackPulse)) {
this.partyAttackPulses.delete(member.id)
}
}
for (const id of this.partyAttackPulses.keys()) {
if (!this.livePartyEffectIds.has(id)) this.partyAttackPulses.delete(id)
}
}
private drawLabel(entity: DrawableEntity) { private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id) let label = this.labels.get(entity.id)
const icon = entity.kind === 'party' ? '!' : entityIcon(entity) const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
@@ -432,6 +647,30 @@ function entityIcon(entity: DrawableEntity): string {
return IWT2_CLASS_METADATA[entity.classId].icon return IWT2_CLASS_METADATA[entity.classId].icon
} }
function applyMotionTint(sprite: Phaser.GameObjects.Image, tint?: number) {
if (tint) {
sprite.setTint(tint)
} else {
sprite.clearTint()
}
}
function gearLayerLevel(entity: Iwt2PartyEntityState, slot: string): number {
if (slot === 'weapon') return entity.gearLevels?.weapon ?? 0
if (slot === 'head') return entity.gearLevels?.helmet ?? 0
if (slot === 'chest') return entity.gearLevels?.chest ?? 0
if (slot === 'legs') return entity.gearLevels?.legs ?? 0
return 0
}
function gearTierAccentColor(level: number): number | undefined {
if (level <= 0) return undefined
if (level <= 2) return 0x55e887
if (level === 3) return 0x58a8ff
if (level === 4) return 0xc56cf0
return 0xffd25f
}
function bossSpriteKey(bossId: Iwt2BossId): string { function bossSpriteKey(bossId: Iwt2BossId): string {
return `iwt2-boss-${bossId}` return `iwt2-boss-${bossId}`
} }
@@ -440,6 +679,14 @@ function classArenaSpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}` return `iwt2-class-${classId}`
} }
function classArenaBodySpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}-body`
}
function classArenaLayerSpriteKey(classId: Iwt2PlayerClassId, layerId: string): string {
return `iwt2-class-${classId}-${layerId}`
}
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string { function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball' return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
} }
+50 -22
View File
@@ -12,7 +12,7 @@ import {
tickIwt2Arena, tickIwt2Arena,
type Iwt2ArenaState, type Iwt2ArenaState,
} from '../sim/arenaState' } from '../sim/arenaState'
import type { Iwt2EntityId } from '../sim' import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim' import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena' import { PhaserArena } from '../render/PhaserArena'
import { import {
@@ -64,6 +64,10 @@ const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
] ]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = [] const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7 const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
type BossArenaScreenProps = { type BossArenaScreenProps = {
bossId: Iwt2BossId bossId: Iwt2BossId
@@ -72,6 +76,7 @@ type BossArenaScreenProps = {
modeLabel?: string modeLabel?: string
save: Iwt2Save save: Iwt2Save
onBack: () => void onBack: () => void
onMainMenu?: () => void
onPvpRequeue?: () => void onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: { roguelikeRun?: {
@@ -84,7 +89,7 @@ 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 bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp' const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp' const pveGearActive = roguelikeRun?.variant !== 'pvp'
@@ -102,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,
@@ -110,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
@@ -119,6 +126,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
combinedBossHealthScale, combinedBossHealthScale,
combinedDamageScale, combinedDamageScale,
createPressureState(roguelikeStage, roguelikeContentType), createPressureState(roguelikeStage, roguelikeContentType),
arenaBounds,
) )
: null : null
)) ))
@@ -182,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(
@@ -190,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()
@@ -204,13 +214,19 @@ 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')
statusRef.current = nextStatus
setStatus(nextStatus) setStatus(nextStatus)
}, []) }, [])
const resumeArena = useCallback(() => {
statusRef.current = 'playing'
setStatus('playing')
}, [])
const abilities = useMemo( const abilities = useMemo(
() => { () => {
const baseAbilities = abilitiesForHealer( const baseAbilities = abilitiesForHealer(
@@ -268,11 +284,16 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return return
} }
if (overlayAction === 'menu') { if (overlayAction === 'menu') {
if (statusRef.current === 'paused') {
const returnToMainMenu = onMainMenu ?? onBack
returnToMainMenu()
return
}
onBack() onBack()
return return
} }
if (statusRef.current === 'paused') { if (statusRef.current === 'paused') {
setStatus('playing') resumeArena()
return return
} }
if (statusRef.current === 'victory' && roguelikeRun) { if (statusRef.current === 'victory' && roguelikeRun) {
@@ -280,10 +301,10 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return return
} }
resetArena() resetArena()
}, [onBack, onPvpRequeue, resetArena, roguelikeRun]) }, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun])
useGameAction((action, device) => { useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') { if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) {
if (action.startsWith('navigate')) { if (action.startsWith('navigate')) {
moveOverlaySelection(action) moveOverlaySelection(action)
return return
@@ -293,10 +314,15 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return return
} }
if (action === 'back') { if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing') if (statusRef.current === 'paused') resumeArena()
return return
} }
} }
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') resumeArena()
return
}
if (statusRef.current === 'playing' && action.startsWith('ability')) { if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)] const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability) if (ability) castAbility(ability)
@@ -320,16 +346,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
return return
} }
if (action.startsWith('targetParty')) { if (action.startsWith('targetParty')) {
if (statusRef.current !== 'playing') return
const index = Number(action.replace('targetParty', '')) - 1 const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index] const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id) if (target) setSelectedPartyId(target.id)
return return
} }
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
}) })
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => { const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
@@ -438,12 +460,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name) const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
const pauseCopy = roguelikeRun?.variant === 'pvp' const pauseCopy = roguelikeRun?.variant === 'pvp'
? undefined ? undefined
: 'Combat is stopped. Resume the fight or leave the current run.' : 'Combat is stopped. Continue the fight or return to the main menu.'
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
? 'Leave'
: roguelikeRun
? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}`
const dualScreenState = useMemo( const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({ () => buildIwt2DualScreenCombatState({
abilities, abilities,
@@ -508,23 +525,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
{pauseCopy && <p>{pauseCopy}</p>} {pauseCopy && <p>{pauseCopy}</p>}
<button <button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''} className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined} data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')} onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')} onPointerDown={() => setSelectedOverlayAction('primary')}
type="button" type="button"
> >
Resume Continue
</button> </button>
<button <button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`} className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined} data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')} onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')} onPointerDown={() => setSelectedOverlayAction('menu')}
type="button" type="button"
> >
{pauseLeaveLabel} Main Menu
</button> </button>
</div> </div>
</div> </div>
@@ -696,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,
@@ -703,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)
@@ -718,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,
+131 -60
View File
@@ -54,6 +54,7 @@ import {
iwt2GearUpgradeCosts, iwt2GearUpgradeCosts,
iwt2InfusionCosts, iwt2InfusionCosts,
isIwt2InfusionUnlocked, isIwt2InfusionUnlocked,
type Iwt2GearStatId,
type Iwt2GearSlotId, type Iwt2GearSlotId,
} from '../content/gear' } from '../content/gear'
import { import {
@@ -1255,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 (
@@ -1546,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) {
@@ -1862,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}
@@ -1900,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
@@ -1949,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])
@@ -1968,7 +1951,7 @@ export function Iwt2CloudSaveScreen({
value: onlineBackupsAvailable ? 'Upload' : 'Current', value: onlineBackupsAvailable ? 'Upload' : 'Current',
disabled: syncingOnlineSave, disabled: syncingOnlineSave,
onConfirm: () => { onConfirm: () => {
void useLocalSave() void handleUseLocalSave()
}, },
}, },
{ {
@@ -1976,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>
) )
@@ -2021,6 +2004,9 @@ export function Iwt2GearUpgradeScreen({
const classProgress = save.gearProgress[selectedClassId] const classProgress = save.gearProgress[selectedClassId]
const selectedSlot = classProgress.slots[selectedSlotId] const selectedSlot = classProgress.slots[selectedSlotId]
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId] const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
const nextLevel = Math.min(5, selectedSlot.level + 1)
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
const selectedClassName = gearClassDisplayName(selectedClassId, save) const selectedClassName = gearClassDisplayName(selectedClassId, save)
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level) const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts) const canUpgrade = selectedSlot.level < 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"> <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]
@@ -2186,6 +2182,16 @@ export function Iwt2GearUpgradeScreen({
<strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong> <strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong>
<small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small> <small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small>
</p> </p>
<div className="iwt2-gear-bonus-grid">
<span>
<strong>Current bonus</strong>
<small>{selectedBonus.text}</small>
</span>
<span>
<strong>{selectedSlot.level >= 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"> <div className="iwt2-gear-cost-list">
{upgradeCosts.length === 0 ? ( {upgradeCosts.length === 0 ? (
<span>Max rank reached.</span> <span>Max rank reached.</span>
@@ -2245,6 +2251,38 @@ function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string {
return role === 'damage' ? 'Damage' : 'Tank' return role === 'damage' ? 'Damage' : 'Tank'
} }
function gearBonusSummary(
statId: Iwt2GearStatId,
level: number,
classId: Iwt2PlayerClassId,
): { label: string, text: string, value: string } {
const bonus = Math.max(0, level)
if (statId === 'maxHealth') return gearBonus('Max health', `+${bonus * 4}%`)
if (statId === 'moveSpeed') return gearBonus('Move speed', `+${formatPercent(bonus * 2.5)}%`)
if (statId === 'damage') return gearBonus('Attack damage', `+${bonus * 4}%`)
if (statId === 'attackCooldown') {
const label = classId === 'healer' ? 'Ability cooldown' : 'Attack cooldown'
return gearBonus(label, `-${bonus * 3}%`)
}
if (statId === 'projectileSpeed') return gearBonus('Projectile speed', `+${bonus * 3}%`)
if (statId === 'hazardDamageTaken') return gearBonus('Hazard damage taken', `-${bonus * 3}%`)
if (statId === 'stunResist') return gearBonus('Stun/knockdown duration', `-${bonus * 8}%`)
if (statId === 'healingPower') return gearBonus('Ability healing', `+${bonus * 4}%`)
return gearBonus('Bonus', '+0%')
}
function gearBonus(label: string, value: string): { label: string, text: string, value: string } {
return { label, text: `${label} ${value}`, value }
}
function infusionAnchorText(level: number): string {
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) { function clampToEnabled(actions: Iwt2NavAction[], index: number) {
if (actions.length === 0) return 0 if (actions.length === 0) return 0
const bounded = Math.min(Math.max(0, index), actions.length - 1) const bounded = Math.min(Math.max(0, index), actions.length - 1)
@@ -2326,6 +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)
} }
+33 -10
View File
@@ -9,6 +9,7 @@ import type {
Iwt2EntityId, Iwt2EntityId,
Iwt2GroundHazardState, Iwt2GroundHazardState,
Iwt2HostileAddState, Iwt2HostileAddState,
Iwt2ArenaBounds,
Iwt2PartyAiRole, Iwt2PartyAiRole,
Iwt2PartyEntityId, Iwt2PartyEntityId,
Iwt2PartyEntityState, Iwt2PartyEntityState,
@@ -66,14 +67,15 @@ export function createInitialIwt2ArenaState(
bossHealthScale = 1, bossHealthScale = 1,
partyDamageTakenScale = 1, partyDamageTakenScale = 1,
roguelikePressure?: Iwt2RoguelikePressureState, roguelikePressure?: Iwt2RoguelikePressureState,
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
): Iwt2ArenaState { ): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId) const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale)) const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale, bounds))
return { return {
schemaVersion: 1, schemaVersion: 1,
time: 0, time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT }, bounds,
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)), party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)),
projectiles: [], projectiles: [],
hostileAdds: [], hostileAdds: [],
hazards: [], hazards: [],
@@ -95,9 +97,9 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
return [primaryBossId, random] return [primaryBossId, random]
} }
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState { function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number, bounds: Iwt2ArenaBounds): Iwt2BossEntityState {
const bossMetadata = IWT2_BOSS_METADATA[bossId] const bossMetadata = IWT2_BOSS_METADATA[bossId]
const position = initialBossPosition(index) const position = scaleArenaPoint(initialBossPosition(index), bounds)
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale))) const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
return { return {
id: bossId, id: bossId,
@@ -123,9 +125,9 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
chargeHitEntityIds: [], chargeHitEntityIds: [],
slamApplied: false, slamApplied: false,
wallContactSeconds: 0, wallContactSeconds: 0,
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds),
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7, fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
fireballTarget: { x: 320, y: 250 }, fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds),
birdWaveThresholdsTriggered: [], birdWaveThresholdsTriggered: [],
mechanicLanes: [], mechanicLanes: [],
mechanicCircles: [], mechanicCircles: [],
@@ -141,6 +143,13 @@ function initialBossPosition(index: number) {
} }
} }
function scaleArenaPoint(point: Iwt2Vec2, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
return {
x: point.x * bounds.width / DEFAULT_ARENA_WIDTH,
y: point.y * bounds.height / DEFAULT_ARENA_HEIGHT,
}
}
function initialBossSpecialCooldown(bossId: Iwt2BossId): number { function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
if (bossId === 'bulldrome') return 2 if (bossId === 'bulldrome') return 2
if (bossId === 'great-jaggi') return 2.4 if (bossId === 'great-jaggi') return 2.4
@@ -324,7 +333,7 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
}) })
} }
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState { function createPartyMember(initial: InitialPartyMember, damageTakenScale: number, bounds: Iwt2ArenaBounds): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId] const metadata = IWT2_CLASS_METADATA[initial.classId]
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1 const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
return { return {
@@ -332,7 +341,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
kind: 'party', kind: 'party',
classId: initial.classId, classId: initial.classId,
aiRole: initial.aiRole, aiRole: initial.aiRole,
position: { x: initial.x, y: initial.y }, position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds),
velocity: { x: 0, y: 0 }, velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 }, facing: { x: 1, y: 0 },
radius: metadata.radius, radius: metadata.radius,
@@ -355,7 +364,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
attackReady: false, attackReady: false,
status: createEmptyStatus(), status: createEmptyStatus(),
hotEffects: [], hotEffects: [],
preferredOffset: { ...initial.preferredOffset }, preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds),
decisionSecondsRemaining: initial.decisionOffset, decisionSecondsRemaining: initial.decisionOffset,
} }
} }
@@ -846,6 +855,13 @@ function applyPartyAttacks(
damage: member.attackDamage, damage: member.attackDamage,
remainingSeconds: 1.2, remainingSeconds: 1.2,
}) })
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
projectileId += 1 projectileId += 1
return { return {
...member, ...member,
@@ -876,6 +892,13 @@ function applyPartyAttacks(
targetId: target.id, targetId: target.id,
value: damage, value: damage,
}) })
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
if (defeated) { if (defeated) {
events.push({ events.push({
id: 0, id: 0,
+3 -1
View File
@@ -7,6 +7,7 @@ import {
import type { import type {
Iwt2ArenaIndicator, Iwt2ArenaIndicator,
Iwt2ArenaInput, Iwt2ArenaInput,
Iwt2ArenaBounds,
Iwt2ArenaState as Iwt2CoreArenaState, Iwt2ArenaState as Iwt2CoreArenaState,
Iwt2BossEntityState, Iwt2BossEntityState,
Iwt2HostileAddState, Iwt2HostileAddState,
@@ -61,8 +62,9 @@ export function createInitialIwt2ArenaState(
bossHealthScale?: number, bossHealthScale?: number,
partyDamageTakenScale?: number, partyDamageTakenScale?: number,
roguelikePressure?: Iwt2RoguelikePressureState, roguelikePressure?: Iwt2RoguelikePressureState,
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState { ): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure)) return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
} }
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
+10 -1
View File
@@ -43,7 +43,16 @@ function applyMemberGearStats(
gearProgress: Iwt2GearProgress, gearProgress: Iwt2GearProgress,
): Iwt2PartyEntityState { ): Iwt2PartyEntityState {
const classProgress = gearProgress[member.classId] const classProgress = gearProgress[member.classId]
let next = { ...member } let next: Iwt2PartyEntityState = {
...member,
gearLevels: {
weapon: classProgress.slots.weapon.level,
helmet: classProgress.slots.helmet.level,
chest: classProgress.slots.chest.level,
legs: classProgress.slots.legs.level,
feet: classProgress.slots.feet.level,
},
}
for (const slotId of IWT2_GEAR_SLOTS) { for (const slotId of IWT2_GEAR_SLOTS) {
const level = classProgress.slots[slotId].level const level = classProgress.slots[slotId].level
if (level <= 0) continue if (level <= 0) continue
+4 -7
View File
@@ -9,18 +9,15 @@ import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5 const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9 const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2 const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE = 1.25
const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure' const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale( export function roguelikeIncomingDamageScale(
_stage: number, stage: number,
contentType: Iwt2RoguelikePressureContentType, contentType: Iwt2RoguelikePressureContentType,
): number { ): number {
const base = contentType === 'dungeon' void stage
? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE void contentType
: ROGUELIKE_RAID_DAMAGE_BASE_SCALE return 1
return base
} }
export function createRoguelikePressureState( export function createRoguelikePressureState(
+3
View File
@@ -1,5 +1,6 @@
import type { Iwt2BossId } from '../content/bosses' import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes' import type { Iwt2PlayerClassId } from '../content/classes'
import type { Iwt2GearLevel, Iwt2GearSlotId } from '../content/gear'
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities' import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
export type Iwt2Vec2 = { export type Iwt2Vec2 = {
@@ -70,6 +71,7 @@ export type Iwt2PartyEntityState = {
mana: number mana: number
maxMana: number maxMana: number
damageDone: number damageDone: number
gearLevels?: Partial<Record<Iwt2GearSlotId, Iwt2GearLevel>>
attackCooldownRemaining: number attackCooldownRemaining: number
castSecondsRemaining: number castSecondsRemaining: number
attackReady: boolean attackReady: boolean
@@ -283,6 +285,7 @@ export type Iwt2GroundHazardState = {
export type Iwt2ArenaEventType = export type Iwt2ArenaEventType =
| 'bossDamaged' | 'bossDamaged'
| 'healerSpellCast' | 'healerSpellCast'
| 'partyAttack'
| 'partyHealed' | 'partyHealed'
| 'partyDamaged' | 'partyDamaged'
| 'partyStunned' | '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