Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6cca2810c | ||
|
|
f48e6130b8 | ||
|
|
61de17d63e | ||
|
|
5629041879 | ||
|
|
ed3f43c93f | ||
|
|
d70941f43e | ||
|
|
de2f86e101 | ||
|
|
62dc51b42a | ||
|
|
defbf0ce10 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -5,6 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -153,6 +154,48 @@ def gitea_request(
|
|||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
data = exc.read()
|
data = exc.read()
|
||||||
return exc.code, data
|
return exc.code, data
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]:
|
||||||
|
curl = shutil.which("curl")
|
||||||
|
if curl is None:
|
||||||
|
raise ReleaseError("curl is required for Gitea asset uploads")
|
||||||
|
|
||||||
|
url = config.gitea_url.rstrip("/") + path
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
curl,
|
||||||
|
"--silent",
|
||||||
|
"--show-error",
|
||||||
|
"--retry",
|
||||||
|
"3",
|
||||||
|
"--retry-all-errors",
|
||||||
|
"--retry-delay",
|
||||||
|
"2",
|
||||||
|
"--request",
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
"--header",
|
||||||
|
f"Authorization: token {config.token}",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{apk_path}",
|
||||||
|
"--write-out",
|
||||||
|
"\n%{http_code}",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
|
||||||
|
raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
|
||||||
|
try:
|
||||||
|
body, status_text = proc.stdout.rsplit(b"\n", 1)
|
||||||
|
return int(status_text), body
|
||||||
|
except ValueError as exc:
|
||||||
|
output = proc.stdout.decode("utf-8", "replace")
|
||||||
|
raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc
|
||||||
|
|
||||||
|
|
||||||
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
||||||
@@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N
|
|||||||
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
||||||
release_id = find_or_create_release(config, log)
|
release_id = find_or_create_release(config, log)
|
||||||
|
|
||||||
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
|
|
||||||
header = (
|
|
||||||
f"--{boundary}\r\n"
|
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
|
|
||||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
|
||||||
).encode("utf-8")
|
|
||||||
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
|
||||||
body = header + apk_path.read_bytes() + footer
|
|
||||||
asset_name = urllib.parse.quote(apk_path.name)
|
asset_name = urllib.parse.quote(apk_path.name)
|
||||||
path = (
|
path = (
|
||||||
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||||
f"/releases/{release_id}/assets?name={asset_name}"
|
f"/releases/{release_id}/assets?name={asset_name}"
|
||||||
)
|
)
|
||||||
status, data = gitea_request(
|
status, data = gitea_upload_asset(config, path, apk_path)
|
||||||
config,
|
|
||||||
"POST",
|
|
||||||
path,
|
|
||||||
body=body,
|
|
||||||
content_type=f"multipart/form-data; boundary={boundary}",
|
|
||||||
)
|
|
||||||
if status == 409:
|
if status == 409:
|
||||||
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
||||||
parse_json_response(status, data, "Upload release asset")
|
parse_json_response(status, data, "Upload release asset")
|
||||||
|
|||||||
@@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip the RESET confirmation when used with --reset-player-data.",
|
help="Skip the RESET confirmation when used with --reset-player-data.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--release-only",
|
||||||
|
metavar="VERSION",
|
||||||
|
help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.yes_reset and not args.reset_player_data:
|
if args.yes_reset and not args.reset_player_data:
|
||||||
parser.error("--yes-reset requires --reset-player-data")
|
parser.error("--yes-reset requires --reset-player-data")
|
||||||
|
if args.release_only and args.reset_player_data:
|
||||||
|
parser.error("--release-only cannot be combined with --reset-player-data")
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) -
|
|||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", "replace")
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
||||||
boundary = "----iwanttoheal-release-boundary"
|
url = GITEA_URL + path
|
||||||
body = (
|
curl = shutil.which("curl")
|
||||||
f"--{boundary}\r\n"
|
if curl is None:
|
||||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
|
raise SystemExit("curl is required for Gitea asset uploads")
|
||||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
|
||||||
).encode("utf-8")
|
proc = subprocess.run(
|
||||||
body += apk.read_bytes()
|
[
|
||||||
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
curl,
|
||||||
headers = {
|
"--silent",
|
||||||
"Authorization": f"token {token}",
|
"--show-error",
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
"--retry",
|
||||||
}
|
"3",
|
||||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
|
"--retry-all-errors",
|
||||||
|
"--retry-delay",
|
||||||
|
"2",
|
||||||
|
"--request",
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
"--header",
|
||||||
|
f"Authorization: token {token}",
|
||||||
|
"--form",
|
||||||
|
f"attachment=@{apk}",
|
||||||
|
"--write-out",
|
||||||
|
"\n%{http_code}",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = proc.stderr.strip() or proc.stdout.strip()
|
||||||
|
raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
body, status_text = proc.stdout.rsplit("\n", 1)
|
||||||
|
status = int(status_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc
|
||||||
|
|
||||||
|
if status >= 400:
|
||||||
|
raise SystemExit(f"Gitea asset upload failed ({status}): {body}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body or "{}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_release(version: str, token: str) -> int:
|
||||||
|
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||||
|
tag = f"v{version}"
|
||||||
|
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
|
||||||
|
req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
release = json.loads(resp.read().decode("utf-8"))
|
||||||
|
release_id = release.get("id")
|
||||||
|
if not release_id:
|
||||||
|
raise SystemExit(f"Gitea release missing id: {release}")
|
||||||
|
print(f"Gitea release exists: {tag} (id {release_id})")
|
||||||
|
return int(release_id)
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code != 404:
|
||||||
detail = exc.read().decode("utf-8", "replace")
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
|
raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
|
||||||
def create_gitea_release(version: str, apk: Path) -> None:
|
|
||||||
token = GITEA_TOKEN.strip()
|
|
||||||
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
|
||||||
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
|
||||||
|
|
||||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
|
||||||
release = request_json(
|
release = request_json(
|
||||||
"POST",
|
"POST",
|
||||||
repo_path + "/releases",
|
repo_path + "/releases",
|
||||||
token,
|
token,
|
||||||
{
|
{
|
||||||
"tag_name": f"v{version}",
|
"tag_name": tag,
|
||||||
"target_commitish": BRANCH,
|
"target_commitish": BRANCH,
|
||||||
"name": f"v{version}",
|
"name": tag,
|
||||||
"body": f"I Want to Heal Android build v{version}",
|
"body": f"I Want to Heal Android build v{version}",
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
@@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None:
|
|||||||
release_id = release.get("id")
|
release_id = release.get("id")
|
||||||
if not release_id:
|
if not release_id:
|
||||||
raise SystemExit(f"Gitea release missing id: {release}")
|
raise SystemExit(f"Gitea release missing id: {release}")
|
||||||
|
print(f"Gitea release created: {tag} (id {release_id})")
|
||||||
|
return int(release_id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_gitea_release(version: str, apk: Path) -> None:
|
||||||
|
token = GITEA_TOKEN.strip()
|
||||||
|
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||||
|
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||||
|
|
||||||
|
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||||
|
release_id = find_or_create_release(version, token)
|
||||||
asset_name = urllib.parse.quote(apk.name)
|
asset_name = urllib.parse.quote(apk.name)
|
||||||
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
||||||
print(f"Gitea release uploaded: v{version}")
|
print(f"Gitea release uploaded: v{version}")
|
||||||
@@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
|
|||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
os.chdir(REPO_ROOT)
|
os.chdir(REPO_ROOT)
|
||||||
|
if args.release_only:
|
||||||
|
version = args.release_only.strip()
|
||||||
|
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
||||||
|
raise SystemExit("Version must look like 1.1.2")
|
||||||
|
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
|
||||||
|
if not apk.exists():
|
||||||
|
raise SystemExit(f"APK not found: {apk}")
|
||||||
|
create_gitea_release(version, apk)
|
||||||
|
return 0
|
||||||
|
|
||||||
version = prompt_version()
|
version = prompt_version()
|
||||||
apk = build_apk(version)
|
apk = build_apk(version)
|
||||||
commit_and_push(version)
|
commit_and_push(version)
|
||||||
|
|||||||
@@ -212,6 +212,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-arena-layout {
|
.iwt2-arena-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 172px minmax(0, 1fr);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
.iwt2-arena-stage {
|
.iwt2-arena-stage {
|
||||||
background: #090c10;
|
background: #090c10;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
grid-column: 2;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -317,6 +320,22 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bar em {
|
||||||
|
color: #fff7df;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
left: 0;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px 0 #050608;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-bar.boss span {
|
.iwt2-bar.boss span {
|
||||||
background: #dc5162;
|
background: #dc5162;
|
||||||
}
|
}
|
||||||
@@ -337,17 +356,18 @@
|
|||||||
.iwt2-party-list {
|
.iwt2-party-list {
|
||||||
background: rgba(15, 19, 26, 0.88);
|
background: rgba(15, 19, 26, 0.88);
|
||||||
border: 0;
|
border: 0;
|
||||||
left: 28px;
|
bottom: 0;
|
||||||
max-height: calc(100% - 112px);
|
left: 0;
|
||||||
|
max-height: none;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 28px;
|
top: 0;
|
||||||
width: 172px;
|
width: 172px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-content: start;
|
align-content: center;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,7 +1119,8 @@
|
|||||||
.iwt2-gear-class,
|
.iwt2-gear-class,
|
||||||
.iwt2-gear-slot,
|
.iwt2-gear-slot,
|
||||||
.iwt2-infusion-row,
|
.iwt2-infusion-row,
|
||||||
.iwt2-gear-upgrade-button {
|
.iwt2-gear-upgrade-button,
|
||||||
|
.iwt2-gear-back {
|
||||||
background: #151922;
|
background: #151922;
|
||||||
border: 2px solid #08090d;
|
border: 2px solid #08090d;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -1177,7 +1198,8 @@
|
|||||||
.iwt2-gear-class.game-selected,
|
.iwt2-gear-class.game-selected,
|
||||||
.iwt2-gear-slot.game-selected,
|
.iwt2-gear-slot.game-selected,
|
||||||
.iwt2-infusion-row.game-selected,
|
.iwt2-infusion-row.game-selected,
|
||||||
.iwt2-gear-upgrade-button.game-selected {
|
.iwt2-gear-upgrade-button.game-selected,
|
||||||
|
.iwt2-gear-back.game-selected {
|
||||||
outline-color: #e5b95f;
|
outline-color: #e5b95f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,6 +1209,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-back {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-upgrade-button:disabled,
|
.iwt2-gear-upgrade-button:disabled,
|
||||||
.iwt2-infusion-row:disabled {
|
.iwt2-infusion-row:disabled {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1219,6 +1247,28 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid span {
|
||||||
|
background: #0d1118;
|
||||||
|
border: 1px solid #29313d;
|
||||||
|
display: block;
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid strong {
|
||||||
|
color: #f2c96d;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid small {
|
||||||
|
color: #e5edf6;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-cost-list span.met {
|
.iwt2-gear-cost-list span.met {
|
||||||
color: #9ef0bd;
|
color: #9ef0bd;
|
||||||
}
|
}
|
||||||
@@ -1379,6 +1429,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) and (max-height: 620px) {
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.iwt2-arena-layout {
|
||||||
|
grid-template-columns: 154px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.game-shell.iwt2-shell {
|
.game-shell.iwt2-shell {
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
width: min(100%, calc(100% - 20px));
|
width: min(100%, calc(100% - 20px));
|
||||||
@@ -1618,11 +1672,29 @@
|
|||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-back {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-detail {
|
.iwt2-gear-detail {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid span {
|
||||||
|
padding: 5px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-bonus-grid strong,
|
||||||
|
.iwt2-gear-bonus-grid small {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-infusion-row {
|
.iwt2-infusion-row {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
grid-template-columns: 28px minmax(0, 1fr);
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
@@ -1700,20 +1772,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-list {
|
.iwt2-party-list {
|
||||||
gap: 5px;
|
align-content: center;
|
||||||
left: 10px;
|
gap: 7px;
|
||||||
max-height: calc(100% - 84px);
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
max-height: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
top: 10px;
|
top: 0;
|
||||||
width: 154px;
|
width: 154px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row {
|
.iwt2-party-row {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
grid-template-columns: 22px minmax(0, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
height: 62px;
|
height: 70px;
|
||||||
min-height: 62px;
|
min-height: 70px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,8 +1821,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row .iwt2-bar {
|
.iwt2-party-row .iwt2-bar {
|
||||||
height: 7px;
|
height: 12px;
|
||||||
margin-top: 2px;
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row .iwt2-bar.mana {
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-effects {
|
.iwt2-party-effects {
|
||||||
@@ -1768,6 +1846,7 @@
|
|||||||
|
|
||||||
.iwt2-ability-bar {
|
.iwt2-ability-bar {
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
display: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: repeat(6, 36px);
|
grid-template-columns: repeat(6, 36px);
|
||||||
max-width: 256px;
|
max-width: 256px;
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export function CombatScreen({
|
|||||||
roguelikeAbilityLabelMode = 'ability',
|
roguelikeAbilityLabelMode = 'ability',
|
||||||
roguelikeEncounterPool,
|
roguelikeEncounterPool,
|
||||||
onExit,
|
onExit,
|
||||||
|
onMainMenu = onExit,
|
||||||
onProfileUpdated,
|
onProfileUpdated,
|
||||||
}: {
|
}: {
|
||||||
difficulty: Difficulty
|
difficulty: Difficulty
|
||||||
@@ -279,6 +280,7 @@ export function CombatScreen({
|
|||||||
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
||||||
roguelikeEncounterPool?: DungeonEncounter[]
|
roguelikeEncounterPool?: DungeonEncounter[]
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onMainMenu?: () => void
|
||||||
onProfileUpdated: (profile: CharacterProfile) => void
|
onProfileUpdated: (profile: CharacterProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const staticEncounters = useMemo(
|
const staticEncounters = useMemo(
|
||||||
@@ -1399,10 +1401,10 @@ export function CombatScreen({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Game Paused</p>
|
<p className="eyebrow">Game Paused</p>
|
||||||
<h2>{dungeon.name}</h2>
|
<h2>{dungeon.name}</h2>
|
||||||
<p>Combat is stopped. Resume the fight or leave the current run.</p>
|
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
|
||||||
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
<button onClick={() => setPaused(false)} type="button">Continue</button>
|
||||||
<button className="secondary-result-button" onClick={onExit} type="button">
|
<button className="secondary-result-button" onClick={onMainMenu} type="button">
|
||||||
Leave {contentName}
|
Main Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export function PvPRoguelikeScreen({
|
|||||||
contentType,
|
contentType,
|
||||||
encounterPool,
|
encounterPool,
|
||||||
onExit,
|
onExit,
|
||||||
|
onMainMenu = onExit,
|
||||||
onProfileUpdated,
|
onProfileUpdated,
|
||||||
}: {
|
}: {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
@@ -301,6 +302,7 @@ export function PvPRoguelikeScreen({
|
|||||||
contentType: PvpContentType
|
contentType: PvpContentType
|
||||||
encounterPool: DungeonEncounter[]
|
encounterPool: DungeonEncounter[]
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onMainMenu?: () => void
|
||||||
onProfileUpdated: (profile: CharacterProfile) => void
|
onProfileUpdated: (profile: CharacterProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||||
@@ -1423,14 +1425,14 @@ export function PvPRoguelikeScreen({
|
|||||||
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
||||||
if (entry.kind === 'queueBack') onExit()
|
if (entry.kind === 'queueBack') onExit()
|
||||||
else if (entry.kind === 'pauseResume') setPaused(false)
|
else if (entry.kind === 'pauseResume') setPaused(false)
|
||||||
else if (entry.kind === 'pauseLeave') onExit()
|
else if (entry.kind === 'pauseLeave') onMainMenu()
|
||||||
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
||||||
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
||||||
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
||||||
}
|
}
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
if (status === 'queueing' || status === 'round-countdown') {
|
if (status === 'queueing') {
|
||||||
if (action === 'back' || action === 'pause') onExit()
|
if (action === 'back' || action === 'pause') onExit()
|
||||||
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
return
|
return
|
||||||
@@ -1447,6 +1449,13 @@ export function PvPRoguelikeScreen({
|
|||||||
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (status === 'round-countdown') {
|
||||||
|
if (action === 'back' || action === 'pause') {
|
||||||
|
setOverlaySelectedIndex(0)
|
||||||
|
setPaused(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (status === 'upgrade-choice') {
|
if (status === 'upgrade-choice') {
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
openPvpOverlayEntry(activePvpOverlayEntry())
|
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||||
@@ -1780,16 +1789,16 @@ export function PvPRoguelikeScreen({
|
|||||||
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Resume
|
Continue
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
onClick={onExit}
|
onClick={onMainMenu}
|
||||||
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Leave
|
Main Menu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
export function ArenaBar({
|
export function ArenaBar({
|
||||||
className = '',
|
className = '',
|
||||||
current,
|
current,
|
||||||
|
label,
|
||||||
max,
|
max,
|
||||||
shield = 0,
|
shield = 0,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
current: number
|
current: number
|
||||||
|
label?: string
|
||||||
max: number
|
max: number
|
||||||
shield?: number
|
shield?: number
|
||||||
}) {
|
}) {
|
||||||
@@ -15,6 +17,7 @@ export function ArenaBar({
|
|||||||
<div className={`iwt2-bar ${className}`}>
|
<div className={`iwt2-bar ${className}`}>
|
||||||
<span style={{ width: `${percent}%` }} />
|
<span style={{ width: `${percent}%` }} />
|
||||||
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
||||||
|
{label && <em>{label}</em>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ export function PartyFrames({
|
|||||||
<div>
|
<div>
|
||||||
<div className="iwt2-party-row-title">
|
<div className="iwt2-party-row-title">
|
||||||
<strong>{meta.name}</strong>
|
<strong>{meta.name}</strong>
|
||||||
<small>{Math.ceil(member.health)}</small>
|
|
||||||
</div>
|
</div>
|
||||||
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
|
<ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
|
||||||
{member.maxMana > 0 && (
|
{member.maxMana > 0 && (
|
||||||
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
spriteYOffsetScale: -0.1,
|
spriteYOffsetScale: -0.1,
|
||||||
color: '#d66a35',
|
color: '#d66a35',
|
||||||
accentColor: '#ffd166',
|
accentColor: '#ffd166',
|
||||||
maxHealth: 400,
|
maxHealth: 750,
|
||||||
radius: 29,
|
radius: 29,
|
||||||
moveSpeed: 118,
|
moveSpeed: 118,
|
||||||
meleeRange: 56,
|
meleeRange: 56,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
Iwt2ArenaIndicator,
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaInput,
|
Iwt2ArenaInput,
|
||||||
|
Iwt2ArenaBounds,
|
||||||
Iwt2ArenaState as Iwt2CoreArenaState,
|
Iwt2ArenaState as Iwt2CoreArenaState,
|
||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
@@ -61,8 +62,9 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossHealthScale?: number,
|
bossHealthScale?: number,
|
||||||
partyDamageTakenScale?: number,
|
partyDamageTakenScale?: number,
|
||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
bounds?: Iwt2ArenaBounds,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
|
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
|
|||||||
@@ -43,7 +43,16 @@ function applyMemberGearStats(
|
|||||||
gearProgress: Iwt2GearProgress,
|
gearProgress: Iwt2GearProgress,
|
||||||
): Iwt2PartyEntityState {
|
): Iwt2PartyEntityState {
|
||||||
const classProgress = gearProgress[member.classId]
|
const classProgress = gearProgress[member.classId]
|
||||||
let next = { ...member }
|
let next: Iwt2PartyEntityState = {
|
||||||
|
...member,
|
||||||
|
gearLevels: {
|
||||||
|
weapon: classProgress.slots.weapon.level,
|
||||||
|
helmet: classProgress.slots.helmet.level,
|
||||||
|
chest: classProgress.slots.chest.level,
|
||||||
|
legs: classProgress.slots.legs.level,
|
||||||
|
feet: classProgress.slots.feet.level,
|
||||||
|
},
|
||||||
|
}
|
||||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
const level = classProgress.slots[slotId].level
|
const level = classProgress.slots[slotId].level
|
||||||
if (level <= 0) continue
|
if (level <= 0) continue
|
||||||
|
|||||||
@@ -9,18 +9,15 @@ import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
|
|||||||
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
|
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
|
||||||
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
|
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
|
||||||
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
|
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
|
||||||
const ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE = 1.25
|
|
||||||
const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45
|
|
||||||
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
|
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
|
||||||
|
|
||||||
export function roguelikeIncomingDamageScale(
|
export function roguelikeIncomingDamageScale(
|
||||||
_stage: number,
|
stage: number,
|
||||||
contentType: Iwt2RoguelikePressureContentType,
|
contentType: Iwt2RoguelikePressureContentType,
|
||||||
): number {
|
): number {
|
||||||
const base = contentType === 'dungeon'
|
void stage
|
||||||
? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE
|
void contentType
|
||||||
: ROGUELIKE_RAID_DAMAGE_BASE_SCALE
|
return 1
|
||||||
return base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRoguelikePressureState(
|
export function createRoguelikePressureState(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||||
|
import type { Iwt2GearLevel, Iwt2GearSlotId } from '../content/gear'
|
||||||
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
|
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2Vec2 = {
|
export type Iwt2Vec2 = {
|
||||||
@@ -70,6 +71,7 @@ export type Iwt2PartyEntityState = {
|
|||||||
mana: number
|
mana: number
|
||||||
maxMana: number
|
maxMana: number
|
||||||
damageDone: number
|
damageDone: number
|
||||||
|
gearLevels?: Partial<Record<Iwt2GearSlotId, Iwt2GearLevel>>
|
||||||
attackCooldownRemaining: number
|
attackCooldownRemaining: number
|
||||||
castSecondsRemaining: number
|
castSecondsRemaining: number
|
||||||
attackReady: boolean
|
attackReady: boolean
|
||||||
@@ -283,6 +285,7 @@ export type Iwt2GroundHazardState = {
|
|||||||
export type Iwt2ArenaEventType =
|
export type Iwt2ArenaEventType =
|
||||||
| 'bossDamaged'
|
| 'bossDamaged'
|
||||||
| 'healerSpellCast'
|
| 'healerSpellCast'
|
||||||
|
| 'partyAttack'
|
||||||
| 'partyHealed'
|
| 'partyHealed'
|
||||||
| 'partyDamaged'
|
| 'partyDamaged'
|
||||||
| 'partyStunned'
|
| 'partyStunned'
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 75 KiB |