Compare commits

...
7 Commits
Author SHA1 Message Date
Warren H ed3f43c93f Android build v1.1.28 2026-07-06 13:03:33 -04:00
Warren H d70941f43e Android build v1.1.27 2026-07-06 11:46:53 -04:00
Warren H de2f86e101 Android build v1.1.26 2026-07-05 23:31:04 -04:00
Warren H 62dc51b42a Android build v1.1.26 2026-07-05 23:21:37 -04:00
Warren H defbf0ce10 Android build v1.1.25 2026-07-05 22:51:34 -04:00
Warren H d70f916f31 Android build v1.1.24 2026-07-05 22:49:25 -04:00
Warren H 9708ba9e40 Android build v1.1.23 2026-07-05 22:48:56 -04:00
123 changed files with 5023 additions and 527 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 101
versionName "1.1.22"
versionCode 108
versionName "1.1.28"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+7
View File
@@ -173,6 +173,13 @@ CREATE TABLE IF NOT EXISTS accounts (
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS account_iwt2_saves (
account_id INTEGER PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
save_json TEXT NOT NULL,
character_level INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS account_ip_allowances (
ip_address TEXT PRIMARY KEY,
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
+6
View File
@@ -96,6 +96,12 @@ curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$REL
-F "attachment=@$APK"
```
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
```sh
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Barroth boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Mirehorn boss sprite">
<path d="M20 80c6-31 35-49 68-42 31 6 48 27 43 52-5 26-34 41-68 34-30-6-48-24-43-44Z" fill="#8d714d"/>
<path d="M44 49c16-15 49-17 72-1-28 0-53 6-76 20Z" fill="#d6bd80"/>
<path d="M56 29h48l17 24-23 17H55L35 52Z" fill="#6a5137"/>

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 766 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Bulldrome boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Bristlemaw boss sprite">
<path d="M22 80c4-31 32-50 64-43 26 6 42 23 43 45 2 23-16 39-43 41-34 2-62-14-64-43Z" fill="#7a4b35"/>
<path d="M42 91c11 15 42 22 67 10-9 16-28 24-52 18-13-4-23-13-28-24l13-4Z" fill="#3b2d28" opacity=".55"/>
<path d="M42 51c11-12 31-17 52-12 14 3 25 10 31 21-25-9-53-11-83-9Z" fill="#b48762"/>

Before

Width:  |  Height:  |  Size: 981 B

After

Width:  |  Height:  |  Size: 982 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Great Jaggi boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Packfang Alpha boss sprite">
<path d="M21 78c8-28 38-42 69-32 24 8 36 27 27 49-9 23-38 32-66 20-22-9-35-21-30-37Z" fill="#3f8f73"/>
<path d="M54 48c15-15 38-14 55 1-21 0-39 6-54 19Z" fill="#b8f0aa"/>
<path d="M28 79 7 58l33 5Z" fill="#2a6e58"/>

Before

Width:  |  Height:  |  Size: 825 B

After

Width:  |  Height:  |  Size: 828 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Khezu boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Palevolt Maw boss sprite">
<path d="M27 82c2-28 27-43 55-40 25 3 45 20 45 44 0 23-21 39-50 39-30 0-52-18-50-43Z" fill="#d8d7c9"/>
<path d="M42 55c14-13 47-13 66 0-23-2-45 2-68 16Z" fill="#fff4df"/>
<path d="M37 70 10 55l15 40 30 2Z" fill="#b9b7aa"/>

Before

Width:  |  Height:  |  Size: 839 B

After

Width:  |  Height:  |  Size: 846 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Rathian boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Verdant Wyrm boss sprite">
<path d="M26 77c6-30 35-48 66-40 27 7 42 30 36 55-6 24-34 38-64 29-26-8-43-25-38-44Z" fill="#4d9a58"/>
<path d="M48 51c13-17 40-22 64-4-25 0-44 6-59 20Z" fill="#f0c24d"/>
<path d="M35 69 5 38l13 54 36 10Z" fill="#3f7c4a"/>

Before

Width:  |  Height:  |  Size: 844 B

After

Width:  |  Height:  |  Size: 849 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Tobi Kadachi boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Stormtail boss sprite">
<path d="M25 79c7-27 34-42 65-34 24 6 38 23 35 45-3 23-28 37-58 30-27-6-47-24-42-41Z" fill="#6a79bd"/>
<path d="M49 50c13-14 38-18 61-3-21 1-40 8-56 22Z" fill="#a8f3ff"/>
<path d="M40 69 8 49l20 46 34 5Z" fill="#515d9d"/>

Before

Width:  |  Height:  |  Size: 877 B

After

Width:  |  Height:  |  Size: 874 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Yian Kut Ku boss sprite">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Emberquill boss sprite">
<path d="M28 84c3-29 28-45 56-38 24 6 39 24 35 48-3 22-24 34-51 31-25-3-42-18-40-41Z" fill="#d66a35"/>
<path d="M52 51c9-14 29-18 49-5-16 0-31 5-43 16Z" fill="#ffd166"/>
<path d="M38 67 9 45l12 45 29 5Z" fill="#f28a3e"/>

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 895 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.
+44 -15
View File
@@ -5,6 +5,7 @@ import json
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
@@ -153,6 +154,48 @@ def gitea_request(
except urllib.error.HTTPError as exc:
data = exc.read()
return exc.code, data
except urllib.error.URLError as exc:
raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc
def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]:
curl = shutil.which("curl")
if curl is None:
raise ReleaseError("curl is required for Gitea asset uploads")
url = config.gitea_url.rstrip("/") + path
proc = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"2",
"--request",
"POST",
url,
"--header",
f"Authorization: token {config.token}",
"--form",
f"attachment=@{apk_path}",
"--write-out",
"\n%{http_code}",
],
check=False,
capture_output=True,
)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
try:
body, status_text = proc.stdout.rsplit(b"\n", 1)
return int(status_text), body
except ValueError as exc:
output = proc.stdout.decode("utf-8", "replace")
raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc
def parse_json_response(status: int, data: bytes, action: str) -> dict:
@@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N
raise ReleaseError(f"APK not found for release upload: {apk_path}")
release_id = find_or_create_release(config, log)
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = header + apk_path.read_bytes() + footer
asset_name = urllib.parse.quote(apk_path.name)
path = (
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
f"/releases/{release_id}/assets?name={asset_name}"
)
status, data = gitea_request(
config,
"POST",
path,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
status, data = gitea_upload_asset(config, path, apk_path)
if status == 409:
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
parse_json_response(status, data, "Upload release asset")
+98 -25
View File
@@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Skip the RESET confirmation when used with --reset-player-data.",
)
parser.add_argument(
"--release-only",
metavar="VERSION",
help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.",
)
args = parser.parse_args(argv)
if args.yes_reset and not args.reset_player_data:
parser.error("--yes-reset requires --reset-player-data")
if args.release_only and args.reset_player_data:
parser.error("--release-only cannot be combined with --reset-player-data")
return args
@@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) -
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc
def upload_asset(path: str, token: str, apk: Path) -> dict:
boundary = "----iwanttoheal-release-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
body += apk.read_bytes()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
headers = {
"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
url = GITEA_URL + path
curl = shutil.which("curl")
if curl is None:
raise SystemExit("curl is required for Gitea asset uploads")
proc = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"2",
"--request",
"POST",
url,
"--header",
f"Authorization: token {token}",
"--form",
f"attachment=@{apk}",
"--write-out",
"\n%{http_code}",
],
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
detail = proc.stderr.strip() or proc.stdout.strip()
raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
try:
body, status_text = proc.stdout.rsplit("\n", 1)
status = int(status_text)
except ValueError as exc:
raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc
if status >= 400:
raise SystemExit(f"Gitea asset upload failed ({status}): {body}")
try:
parsed = json.loads(body or "{}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc
if not isinstance(parsed, dict):
raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}")
return parsed
def find_or_create_release(version: str, token: str) -> int:
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
tag = f"v{version}"
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
release = json.loads(resp.read().decode("utf-8"))
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
print(f"Gitea release exists: {tag} (id {release_id})")
return int(release_id)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
if exc.code != 404:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release = request_json(
"POST",
repo_path + "/releases",
token,
{
"tag_name": f"v{version}",
"tag_name": tag,
"target_commitish": BRANCH,
"name": f"v{version}",
"name": tag,
"body": f"I Want to Heal Android build v{version}",
"draft": False,
"prerelease": False,
@@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None:
release_id = release.get("id")
if not release_id:
raise SystemExit(f"Gitea release missing id: {release}")
print(f"Gitea release created: {tag} (id {release_id})")
return int(release_id)
def create_gitea_release(version: str, apk: Path) -> None:
token = GITEA_TOKEN.strip()
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
release_id = find_or_create_release(version, token)
asset_name = urllib.parse.quote(apk.name)
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
print(f"Gitea release uploaded: v{version}")
@@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
os.chdir(REPO_ROOT)
if args.release_only:
version = args.release_only.strip()
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit("Version must look like 1.1.2")
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
if not apk.exists():
raise SystemExit(f"APK not found: {apk}")
create_gitea_release(version, apk)
return 0
version = prompt_version()
apk = build_apk(version)
commit_and_push(version)
+59 -11
View File
@@ -51,6 +51,14 @@ function ensureRuntimeMigrations(database) {
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
WHERE last_saved_at IS NULL OR last_saved_at = ''
`).run()
database.exec(`
CREATE TABLE IF NOT EXISTS account_iwt2_saves (
account_id INTEGER PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
save_json TEXT NOT NULL,
character_level INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
}
function sqliteDateFromMs(value) {
@@ -1384,6 +1392,46 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
}
}
function readIwt2SyncSave(database, accountId) {
const row = database.prepare(`
SELECT save_json AS saveJson
FROM account_iwt2_saves
WHERE account_id = ?
`).get(accountId)
if (!row) return { save: null }
try {
return { save: JSON.parse(row.saveJson) }
} catch {
return { save: null }
}
}
function importIwt2SyncSave(database, accountId, payload) {
const save = payload?.save
if (
!save
|| typeof save !== 'object'
|| Number(save.version) !== 2
|| typeof save.updatedAt !== 'number'
|| !save.character
|| typeof save.character !== 'object'
|| typeof save.character.level !== 'number'
) {
throw new Error('The IWT2 save snapshot is invalid.')
}
const savedAt = sqliteDateFromMs(save.updatedAt)
const characterLevel = clampInteger(save.character.level, 1, 1, 1000000)
database.prepare(`
INSERT INTO account_iwt2_saves (account_id, save_json, character_level, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
save_json = excluded.save_json,
character_level = excluded.character_level,
updated_at = excluded.updated_at
`).run(accountId, JSON.stringify(save), characterLevel, savedAt)
return readIwt2SyncSave(database, accountId)
}
function itemById(database, itemId) {
return database.prepare(`
SELECT
@@ -1544,11 +1592,6 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
`).get(encounterId, difficultyId)
if (!context) throw new Error('That loot table is not available.')
const character = database.prepare('SELECT level FROM characters WHERE id = ?').get(characterId)
if (character.level < context.unlockLevel) {
throw new Error(`${context.difficultyName} unlocks at level ${context.unlockLevel}.`)
}
const entries = database.prepare(`
SELECT
encounter_loot.drop_weight AS dropWeight,
@@ -2249,9 +2292,6 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
JOIN classes ON classes.id = characters.class_id
WHERE characters.id = ?
`).get(characterId)
if (character.level < dungeon.unlockLevel) {
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
}
const maxLevel = Number(
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
)
@@ -2483,9 +2523,6 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
FROM characters
WHERE characters.id = ?
`).get(characterId)
if (character.level < dungeon.unlockLevel) {
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
}
const maxLevel = Number(
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
)
@@ -3137,6 +3174,17 @@ export async function handleApiRequest(request, response, next) {
return
}
if (request.url === '/api/iwt2/sync-save' && request.method === 'GET') {
sendJson(response, 200, readIwt2SyncSave(database, session.accountId))
return
}
if (request.url === '/api/iwt2/sync-save' && request.method === 'PUT') {
const payload = await readJson(request, 512 * 1024)
sendJson(response, 200, importIwt2SyncSave(database, session.accountId, payload))
return
}
if (request.url === '/api/profile' && request.method === 'GET') {
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
return
+708 -11
View File
@@ -111,6 +111,106 @@
margin: 0;
}
.iwt2-menu-screen {
align-items: center;
background: var(--panel);
border: 3px solid #0c0d11;
box-shadow: 7px 7px 0 #08090c;
display: flex;
flex: 1;
margin-top: 12px;
outline: 2px solid var(--edge);
padding: 28px;
}
.iwt2-menu-screen .iwt2-menu-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(4, minmax(0, 1fr));
height: min(100%, 388px);
margin: 0 auto;
max-width: 900px;
width: 100%;
}
.iwt2-menu-screen .iwt2-menu-card {
align-items: center;
background: var(--panel-light);
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
display: flex;
gap: 12px;
justify-content: flex-start;
min-height: 0;
outline: 2px solid #42414c;
padding: 12px;
text-align: left;
}
.iwt2-menu-screen .iwt2-menu-card > span {
align-items: center;
background: #13141a;
border: 2px solid var(--gold);
color: var(--gold);
display: flex;
flex: 0 0 48px;
font-family: var(--pixel-font);
font-size: 16px;
height: 48px;
justify-content: center;
}
.iwt2-menu-screen .iwt2-menu-card strong {
display: block;
font-family: var(--pixel-font);
font-size: 10px;
margin-bottom: 5px;
}
.iwt2-menu-screen .iwt2-menu-card small {
color: var(--muted);
display: block;
font-size: 14px;
line-height: 1.05;
}
.iwt2-menu-screen .iwt2-menu-card.game-selected > span {
background: var(--gold);
color: #13141a;
}
@media (max-height: 620px) {
.iwt2-menu-screen {
margin-top: 8px;
padding: 14px;
}
.iwt2-menu-screen .iwt2-menu-grid {
gap: 8px;
}
.iwt2-menu-screen .iwt2-menu-card {
min-height: 72px;
padding: 10px;
}
.iwt2-menu-screen .iwt2-menu-card > span {
flex-basis: 44px;
font-size: 14px;
height: 44px;
}
.iwt2-menu-screen .iwt2-menu-card strong {
margin-bottom: 4px;
}
.iwt2-menu-screen .iwt2-menu-card small {
font-size: 15px;
}
}
.iwt2-arena-layout {
height: 100vh;
overflow: hidden;
@@ -259,7 +359,7 @@
cursor: pointer;
display: grid;
gap: 8px;
grid-template-columns: 24px minmax(0, 1fr);
grid-template-columns: 28px minmax(0, 1fr);
height: 86px;
min-height: 86px;
outline: 2px solid transparent;
@@ -269,27 +369,38 @@
}
.iwt2-party-row.has-target-binding {
grid-template-columns: 28px 24px minmax(0, 1fr);
grid-template-columns: 28px 28px minmax(0, 1fr);
}
.iwt2-party-row > span {
.iwt2-party-row > .iwt2-party-portrait {
align-items: center;
border: 2px solid #050608;
color: #fff7df;
display: inline-flex;
font-weight: 900;
height: 24px;
height: 28px;
justify-content: center;
width: 24px;
overflow: hidden;
width: 28px;
}
.iwt2-party-portrait img {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}
.iwt2-party-row .iwt2-party-target-key {
align-items: center;
background: #050608;
border-color: #0b0d12;
color: var(--muted);
display: inline-flex;
font-family: var(--pixel-font);
font-size: 0.52rem;
height: 24px;
justify-content: center;
min-width: 24px;
padding: 0 2px;
width: auto;
@@ -591,6 +702,55 @@
padding: 0 14px;
}
.iwt2-pvp-queue-overlay {
align-items: center;
background: rgba(5, 5, 8, 0.86);
display: flex;
inset: 0;
justify-content: center;
padding: 16px;
position: fixed;
z-index: 11;
}
.iwt2-pvp-queue-panel {
align-items: center;
background: var(--panel);
border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 440px;
outline: 2px solid var(--gold);
padding: 28px;
text-align: center;
width: min(100%, 440px);
}
.iwt2-pvp-queue-panel .placeholder-runes {
font-size: 34px;
margin-bottom: 0;
}
.iwt2-pvp-queue-panel h2 {
color: var(--ink);
font-size: 1.8rem;
line-height: 1.1;
margin: 0;
}
.iwt2-pvp-queue-panel small {
color: var(--muted);
font-size: 0.9rem;
line-height: 1.35;
}
.iwt2-pvp-queue-panel .iwt2-result-button {
margin-top: 6px;
min-width: 150px;
}
.iwt2-result-button.is-primary {
background: #263448;
color: #fff4a8;
@@ -658,6 +818,68 @@
font-size: 0.75rem;
}
.iwt2-name-editor {
display: grid;
gap: 14px;
max-width: 640px;
}
.iwt2-name-editor label {
display: grid;
gap: 8px;
}
.iwt2-name-editor label span {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.iwt2-name-editor input {
background: #10141b;
border: 2px solid #090a0d;
color: var(--ink);
font-family: var(--pixel-font);
font-size: 1rem;
min-height: 48px;
outline: 2px solid #34343d;
padding: 10px 12px;
}
.iwt2-name-editor input:focus {
outline-color: #e5b95f;
}
.iwt2-name-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.iwt2-name-grid button {
background: #10141b;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
font-family: var(--pixel-font);
font-size: 0.72rem;
min-height: 42px;
outline: 2px solid #34343d;
padding: 8px 6px;
}
.iwt2-name-grid button.selected {
background: #1c2633;
outline-color: #e5b95f;
}
.iwt2-screen-note {
color: var(--muted);
font-size: 0.8rem;
line-height: 1.45;
margin: 0;
}
.iwt2-action-list,
.iwt2-info-list {
display: grid;
@@ -725,6 +947,98 @@
outline-color: #e5b95f;
}
.iwt2-header-title {
color: #f2c96d;
font-size: 1rem;
margin-left: 10px;
margin-right: auto;
white-space: nowrap;
}
.iwt2-header-back {
margin-left: 10px;
min-height: 32px;
padding: 6px 12px;
}
.iwt2-heading-actions {
align-items: center;
display: flex;
gap: 8px;
}
.iwt2-pager-button {
background: #1a1b22;
border: 2px solid #0a0b0f;
color: var(--ink);
cursor: pointer;
font-family: var(--pixel-font);
font-size: 8px;
min-height: 30px;
outline: 2px solid #4d4b59;
padding: 5px 9px;
}
.iwt2-pager-button:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.55;
}
.iwt2-page-counter {
color: var(--muted);
font-family: var(--pixel-font);
font-size: 8px;
white-space: nowrap;
}
.iwt2-difficulty-strip {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
margin-bottom: 10px;
}
.iwt2-difficulty-strip > button {
background: #151922;
border: 2px solid #08090d;
color: var(--ink);
cursor: pointer;
display: grid;
gap: 3px;
min-height: 44px;
outline: 2px solid #34343d;
padding: 7px 9px;
text-align: left;
}
.iwt2-difficulty-strip > button.active,
.iwt2-difficulty-strip > button.game-selected {
background: #1c2633;
outline-color: #e5b95f;
}
.iwt2-difficulty-strip > button:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.58;
}
.iwt2-difficulty-strip strong,
.iwt2-difficulty-strip small {
display: block;
line-height: 1.1;
}
.iwt2-difficulty-strip strong {
font-size: 0.72rem;
}
.iwt2-difficulty-strip small {
color: var(--muted);
font-size: 0.58rem;
}
.iwt2-dungeon-list {
min-height: 0;
overflow: hidden;
@@ -740,6 +1054,228 @@
padding: 10px 12px;
}
.iwt2-gear-layout {
display: grid;
gap: 14px;
grid-template-columns: 0.8fr 1fr 1.35fr;
min-height: 0;
}
.iwt2-gear-column {
background: #10141b;
border: 2px solid #090a0d;
display: grid;
gap: 10px;
min-height: 0;
outline: 2px solid #34343d;
padding: 12px;
}
.iwt2-gear-screen {
padding-top: 14px;
}
.iwt2-gear-column h2 {
color: #f2c96d;
font-size: 0.82rem;
line-height: 1.1;
margin: 0;
}
.iwt2-gear-class-list,
.iwt2-gear-slot-list,
.iwt2-infusion-list,
.iwt2-gear-cost-list {
display: grid;
gap: 8px;
min-height: 0;
}
.iwt2-gear-class-list,
.iwt2-infusion-list {
overflow-y: auto;
}
.iwt2-gear-class,
.iwt2-gear-slot,
.iwt2-infusion-row,
.iwt2-gear-upgrade-button {
background: #151922;
border: 2px solid #08090d;
color: var(--ink);
cursor: pointer;
font-family: var(--body-font);
outline: 2px solid #34343d;
text-align: left;
}
.iwt2-gear-class {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: 34px minmax(0, 1fr);
min-height: 54px;
padding: 8px;
}
.iwt2-gear-class > span {
align-items: center;
background: color-mix(in srgb, var(--class-color) 25%, #10141b);
border: 2px solid color-mix(in srgb, var(--class-color) 60%, #090a0d);
display: inline-flex;
height: 32px;
justify-content: center;
width: 32px;
}
.iwt2-gear-class > div,
.iwt2-infusion-row > div {
min-width: 0;
}
.iwt2-gear-class strong,
.iwt2-gear-slot strong,
.iwt2-infusion-row strong,
.iwt2-gear-detail strong {
display: block;
font-size: 0.78rem;
line-height: 1.15;
}
.iwt2-gear-class small,
.iwt2-gear-slot small,
.iwt2-infusion-row small,
.iwt2-gear-detail small {
color: var(--muted);
display: block;
font-size: 0.62rem;
line-height: 1.25;
margin-top: 2px;
}
.iwt2-gear-slot {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
min-height: 56px;
padding: 9px 10px;
}
.iwt2-gear-slot em {
color: #fff4a8;
font-size: 0.9rem;
font-style: normal;
}
.iwt2-gear-class.active,
.iwt2-gear-slot.active,
.iwt2-infusion-row.active {
background: #1a2330;
}
.iwt2-gear-class.game-selected,
.iwt2-gear-slot.game-selected,
.iwt2-infusion-row.game-selected,
.iwt2-gear-upgrade-button.game-selected {
outline-color: #e5b95f;
}
.iwt2-gear-upgrade-button {
min-height: 44px;
padding: 10px 12px;
text-align: center;
}
.iwt2-gear-upgrade-button:disabled,
.iwt2-infusion-row:disabled {
color: var(--muted);
cursor: default;
opacity: 0.62;
}
.iwt2-gear-detail-panel {
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
}
.iwt2-gear-detail {
background: #151922;
border: 2px solid #08090d;
display: grid;
gap: 10px;
padding: 10px;
}
.iwt2-gear-detail p {
margin: 0;
}
.iwt2-gear-cost-list span {
background: #0d1118;
border: 1px solid #29313d;
color: var(--muted);
font-size: 0.62rem;
line-height: 1.2;
padding: 6px 8px;
}
.iwt2-gear-bonus-grid {
display: grid;
gap: 7px;
}
.iwt2-gear-bonus-grid span {
background: #0d1118;
border: 1px solid #29313d;
display: block;
padding: 7px 8px;
}
.iwt2-gear-bonus-grid strong {
color: #f2c96d;
font-size: 0.62rem;
}
.iwt2-gear-bonus-grid small {
color: #e5edf6;
font-size: 0.62rem;
}
.iwt2-gear-cost-list span.met {
color: #9ef0bd;
}
.iwt2-gear-cost-list span.missing {
color: #ff9c9c;
}
.iwt2-infusion-row {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: 34px minmax(0, 1fr);
min-height: 74px;
padding: 8px;
}
.iwt2-infusion-row > span {
align-items: center;
background: #202938;
border: 2px solid #08090d;
color: #f2c96d;
display: inline-flex;
height: 32px;
justify-content: center;
width: 32px;
}
.iwt2-gear-message {
color: #fff4a8;
font-size: 0.68rem;
line-height: 1.3;
min-height: 18px;
}
.iwt2-controller-preview {
align-content: center;
background: #141922;
@@ -768,11 +1304,21 @@
}
.iwt2-bottom-display .dual-control-chip {
align-items: center;
background: var(--panel-light);
border: 2px solid #090a0d;
display: inline-flex;
gap: 6px;
outline: 2px solid #494756;
}
.iwt2-bottom-target-icon {
flex: 0 0 auto;
height: 22px;
object-fit: contain;
width: 22px;
}
@media (max-width: 760px) {
.game-version-grid,
@@ -871,6 +1417,7 @@
.iwt2-shell .character-summary {
gap: 7px;
margin-left: auto;
}
.iwt2-shell .character-summary strong {
@@ -881,6 +1428,16 @@
font-size: 6px;
}
.iwt2-header-title {
font-size: 12px;
margin-left: 6px;
}
.iwt2-header-back {
min-height: 30px;
padding: 5px 9px;
}
.iwt2-menu-screen {
flex: 1;
gap: 10px;
@@ -939,6 +1496,11 @@
padding: 10px 12px;
}
.iwt2-gear-screen {
margin-top: 6px;
padding-top: 6px;
}
.iwt2-screen-shell .screen-heading {
padding-bottom: 8px;
}
@@ -958,6 +1520,39 @@
padding: 5px 9px;
}
.iwt2-heading-actions {
gap: 7px;
}
.iwt2-pager-button {
font-size: 8px;
min-height: 30px;
padding: 5px 9px;
}
.iwt2-page-counter {
font-size: 8px;
}
.iwt2-difficulty-strip {
gap: 6px;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
margin-bottom: 7px;
}
.iwt2-difficulty-strip > button {
min-height: 38px;
padding: 6px 8px;
}
.iwt2-difficulty-strip strong {
font-size: 0.62rem;
}
.iwt2-difficulty-strip small {
font-size: 0.52rem;
}
.iwt2-action-list,
.iwt2-info-list {
gap: 7px;
@@ -979,8 +1574,99 @@
}
.iwt2-dungeon-list .iwt2-action-row {
min-height: 58px;
padding: 8px 10px;
}
.iwt2-gear-layout {
gap: 8px;
grid-template-columns: 0.78fr 0.92fr 1.3fr;
min-height: 0;
overflow: hidden;
}
.iwt2-gear-column {
gap: 7px;
overflow: hidden;
padding: 8px;
}
.iwt2-gear-column h2 {
font-size: 0.68rem;
}
.iwt2-gear-class-list,
.iwt2-gear-slot-list,
.iwt2-infusion-list,
.iwt2-gear-cost-list {
gap: 5px;
}
.iwt2-gear-class {
gap: 6px;
grid-template-columns: 28px minmax(0, 1fr);
min-height: 42px;
padding: 6px 9px;
padding: 5px;
}
.iwt2-gear-class > span,
.iwt2-infusion-row > span {
height: 26px;
width: 26px;
}
.iwt2-gear-class strong,
.iwt2-gear-slot strong,
.iwt2-infusion-row strong,
.iwt2-gear-detail strong {
font-size: 0.64rem;
}
.iwt2-gear-class small,
.iwt2-gear-slot small,
.iwt2-infusion-row small,
.iwt2-gear-detail small,
.iwt2-gear-cost-list span {
font-size: 0.52rem;
}
.iwt2-gear-slot {
min-height: 45px;
padding: 6px 8px;
}
.iwt2-gear-upgrade-button {
min-height: 34px;
padding: 7px 9px;
}
.iwt2-gear-detail {
gap: 6px;
padding: 7px;
}
.iwt2-gear-bonus-grid {
gap: 5px;
}
.iwt2-gear-bonus-grid span {
padding: 5px 6px;
}
.iwt2-gear-bonus-grid strong,
.iwt2-gear-bonus-grid small {
font-size: 0.52rem;
}
.iwt2-infusion-row {
gap: 7px;
grid-template-columns: 28px minmax(0, 1fr);
min-height: 58px;
padding: 6px;
}
.iwt2-gear-message {
font-size: 0.54rem;
}
.iwt2-action-row strong,
@@ -1060,21 +1746,21 @@
.iwt2-party-row {
gap: 5px;
grid-template-columns: 20px minmax(0, 1fr);
grid-template-columns: 22px minmax(0, 1fr);
height: 62px;
min-height: 62px;
padding: 4px;
}
.iwt2-party-row.has-target-binding {
grid-template-columns: 24px 20px minmax(0, 1fr);
grid-template-columns: 24px 22px minmax(0, 1fr);
}
.iwt2-party-row > span,
.iwt2-party-row > .iwt2-party-portrait,
.iwt2-party-row .iwt2-party-target-key {
height: 20px;
height: 22px;
min-width: 20px;
width: 20px;
width: 22px;
}
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
@@ -11980,6 +12666,17 @@ h2 {
flex: 1 1 auto;
}
.iwt2-difficulty-strip {
gap: 4px;
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
margin-bottom: 5px;
}
.iwt2-difficulty-strip > button {
min-height: 34px;
padding: 5px 7px;
}
.iwt2-action-row:not(:has(small)),
.iwt2-info-row:not(:has(small)) {
min-height: 32px;
+6 -1
View File
@@ -116,7 +116,12 @@ function App() {
}
if (selectedVersion === 'iwt2') {
return <IWantToHeal2App onBackToGameSelect={() => setSelectedVersion(null)} />
return (
<IWantToHeal2App
onlineBackupsAvailable={authSession.account?.id !== -1}
onBackToGameSelect={() => setSelectedVersion(null)}
/>
)
}
return (
+9 -3
View File
@@ -9,9 +9,13 @@ export type RewardSummaryBase = {
unlockedAbilities: DungeonReward['unlockedAbilities']
}
export type RunBossCoinAward = NonNullable<DungeonReward['bonusItem']> & {
sourceLabel: string
}
export type PvpRunRewardSummary = RewardSummaryBase & {
bossesKilled: number
loot: Array<NonNullable<DungeonReward['bonusItem']>>
loot: RunBossCoinAward[]
}
export function createEmptyRewardSummary(): RewardSummaryBase {
@@ -60,11 +64,13 @@ export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
export function mergePvpRunRewardSummary(
current: PvpRunRewardSummary,
reward: DungeonReward,
{ bossKilled }: { bossKilled: boolean },
{ bossKilled, sourceLabel }: { bossKilled: boolean; sourceLabel?: string },
) {
return {
...mergeDungeonRewardSummary(current, reward),
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
loot: reward.bonusItem
? [...current.loot, { ...reward.bonusItem, sourceLabel: sourceLabel ?? `Boss ${current.bossesKilled + 1}` }]
: current.loot,
}
}
+43 -11
View File
@@ -3,7 +3,6 @@ import {
completeDungeon,
completeRoguelike,
loadProfile,
recordBossKill,
type DungeonReward,
rollEncounterLoot,
type LootRoll,
@@ -55,6 +54,7 @@ import {
createPveCombatState,
createPveRoguelikeRunStart,
} from '../combat/pveRoguelikeRunSetup'
import type { RunBossCoinAward } from '../combat/rewardSummaries'
import {
appendCombatLog,
type BasicFloatingCombatText,
@@ -79,7 +79,7 @@ import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { PartyMemberFrame } from './PartyFrames'
import { ResourceBar, SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
DualScreenTopCombat,
@@ -236,6 +236,7 @@ function makeRoguelikeSegment(
mode: RoguelikeMode,
): RoguelikeEncounter[] {
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
pool,
stage,
@@ -243,8 +244,8 @@ function makeRoguelikeSegment(
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)),
partyDamageScale: 0.9 + stage * 0.05,
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
partyDamageScale: 0.95,
idBase: 900000,
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
extraFields: (_encounter, isBoss, selectedMechanics) => ({
@@ -265,6 +266,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode = 'ability',
roguelikeEncounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
difficulty: Difficulty
@@ -278,6 +280,7 @@ export function CombatScreen({
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
roguelikeEncounterPool?: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const staticEncounters = useMemo(
@@ -359,6 +362,7 @@ export function CombatScreen({
const [reward, setReward] = useState<DungeonReward | null>(null)
const [rewardError, setRewardError] = useState('')
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
const [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
const [showEndLog, setShowEndLog] = useState(false)
const {
floatingTexts,
@@ -540,9 +544,31 @@ export function CombatScreen({
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
if (recordedBossKillIdsRef.current.has(key)) return
recordedBossKillIdsRef.current.add(key)
recordBossKill(encounter.id, { petVariant: 'purple' })
completeRoguelike(
dungeon.id,
difficulty.id,
0,
0,
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
{
bossesCleared: 0,
fightsCleared: 0,
lootSourceEncounterId: encounter.id,
roguelikeStage,
},
)
.then((result) => {
onProfileUpdated(result.profile)
if (result.bonusItem) {
setRoguelikeBossCoins((current) => [...current, {
...result.bonusItem!,
sourceLabel: encounter.enemyName,
}])
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
'loot',
)
}
if (result.petAwarded) {
addLog(
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
@@ -552,11 +578,11 @@ export function CombatScreen({
})
.catch((reason: unknown) => {
addLog(
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
'danger',
)
})
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
const resetRun = useCallback(() => {
const nextRoguelikeEncounters = roguelikeMode
@@ -581,6 +607,7 @@ export function CombatScreen({
setReward(setup.defaults.reward)
setRewardError(setup.defaults.rewardError)
setLootRolls(setup.defaults.lootRolls)
setRoguelikeBossCoins([])
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
@@ -1374,10 +1401,10 @@ export function CombatScreen({
<div>
<p className="eyebrow">Game Paused</p>
<h2>{dungeon.name}</h2>
<p>Combat is stopped. Resume the fight or leave the current run.</p>
<button onClick={() => setPaused(false)} type="button">Resume</button>
<button className="secondary-result-button" onClick={onExit} type="button">
Leave {contentName}
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
<button onClick={() => setPaused(false)} type="button">Continue</button>
<button className="secondary-result-button" onClick={onMainMenu} type="button">
Main Menu
</button>
</div>
</div>
@@ -1434,7 +1461,11 @@ export function CombatScreen({
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
{isRoguelike && <p>Run totals</p>}
<RewardXpSummary reward={reward} />
{isRoguelike && (
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
)}
{!isRoguelike && (
<>
<p>Component tier: item level {reward.droppedItemLevel}.</p>
@@ -1459,6 +1490,7 @@ export function CombatScreen({
<>
<RewardXpSummary reward={reward} />
<p>{encounterIndex} encounters cleared.</p>
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
<p className="efficiency-result">
{reward.resourceSpent} {gameClass.resourceName} spent
<small>{reward.durationSeconds}s survived</small>
+27 -10
View File
@@ -229,6 +229,7 @@ function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
return buildRoguelikeSegment<BossMechanic, {
bossMechanics: BossMechanic[]
sourceEncounterId?: number
@@ -240,8 +241,8 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
damageScale: 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14),
partyDamageScale: 0.85 + stage * 0.04,
damageScale: stageOneDamageScale,
partyDamageScale: 0.89,
idBase: 910000,
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
extraFields: (encounter, isBoss, selectedMechanics) => ({
@@ -293,6 +294,7 @@ export function PvPRoguelikeScreen({
contentType,
encounterPool,
onExit,
onMainMenu = onExit,
onProfileUpdated,
}: {
profile: CharacterProfile
@@ -300,6 +302,7 @@ export function PvPRoguelikeScreen({
contentType: PvpContentType
encounterPool: DungeonEncounter[]
onExit: () => void
onMainMenu?: () => void
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
@@ -537,7 +540,10 @@ export function PvPRoguelikeScreen({
)
.then((result) => {
setReward(result)
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: isBossReward }))
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
bossKilled: isBossReward,
sourceLabel: rewardEncounter?.enemyName,
}))
onProfileUpdated(result.profile)
if (result.experienceGained > 0) {
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
@@ -579,7 +585,10 @@ export function PvPRoguelikeScreen({
)
.then((result) => {
setReward(result)
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: false }))
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
bossKilled: false,
sourceLabel: 'Match Win',
}))
onProfileUpdated(result.profile)
if (result.experienceGained > 0) {
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
@@ -1416,14 +1425,14 @@ export function PvPRoguelikeScreen({
if (!entry || pvpOverlayEntryDisabled(entry)) return
if (entry.kind === 'queueBack') onExit()
else if (entry.kind === 'pauseResume') setPaused(false)
else if (entry.kind === 'pauseLeave') onExit()
else if (entry.kind === 'pauseLeave') onMainMenu()
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
}
useGameAction((action) => {
if (status === 'queueing' || status === 'round-countdown') {
if (status === 'queueing') {
if (action === 'back' || action === 'pause') onExit()
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
return
@@ -1440,6 +1449,13 @@ export function PvPRoguelikeScreen({
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
return
}
if (status === 'round-countdown') {
if (action === 'back' || action === 'pause') {
setOverlaySelectedIndex(0)
setPaused(true)
}
return
}
if (status === 'upgrade-choice') {
if (action === 'confirm') {
openPvpOverlayEntry(activePvpOverlayEntry())
@@ -1773,16 +1789,16 @@ export function PvPRoguelikeScreen({
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
type="button"
>
Resume
Continue
</button>
<button
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
onClick={onExit}
onClick={onMainMenu}
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
type="button"
>
Leave
Main Menu
</button>
</div>
</div>
@@ -1808,11 +1824,12 @@ export function PvPRoguelikeScreen({
>
<p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary">
<p>Run totals</p>
<p>{runSummary.bossesKilled} bosses killed.</p>
<RewardXpSummary reward={runSummary} />
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
<PvpRunLootList loot={runSummary.loot} />
<PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
{reward && runSummary.bossesKilled === 0 && (
<>
<RewardXpSummary reward={reward} />
+85 -27
View File
@@ -8,8 +8,9 @@ import {
type Spell,
} from '../game'
import { completeRoguelike, recordPvpMatch } from '../profile'
import type { CharacterProfile } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs'
import { PartyMemberFrame } from './PartyFrames'
import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
@@ -64,9 +65,10 @@ import {
import {
createEmptyRewardSummary,
mergeDungeonRewardSummary,
type RunBossCoinAward,
type RewardSummaryBase,
} from '../combat/rewardSummaries'
import { RewardXpSummary } from './RewardPanels'
import { PvpRunLootList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
publishPvpMatchState,
@@ -158,6 +160,25 @@ function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
}
function starterSpellsForClass(gameClass: CharacterProfile['classes'][number]) {
return gameClass.spells
.filter((spell) => spell.unlockLevel === 1)
.slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1)))
}
function randomCpuClass(classes: CharacterProfile['classes'], fallbackId: CharacterProfile['classes'][number]['id']) {
const pool = classes.length > 0 ? classes : []
return pool[Math.floor(Math.random() * pool.length)] ?? classes.find((candidate) => candidate.id === fallbackId)
}
function stadiumBossCoinSource(bosses: DungeonEncounter[], stage: number) {
const targetItemLevel = roguelikeCoinItemLevel(stage)
const eligible = bosses.filter((boss) => boss.lootTables.some((item) => item.itemLevel === targetItemLevel))
const pool = eligible.length > 0 ? eligible : bosses
return pool.length > 0 ? pool[(stage - 1) % pool.length] : undefined
}
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
const directName = slotSpellName('1', spells, 'Mend')
const sustainName = slotSpellName('2', spells, 'Renew')
@@ -330,11 +351,11 @@ export function PvpStadiumScreen({
onProfileUpdated: (profile: CharacterProfile) => void
}) {
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
const starterSpells = useMemo(() => gameClass.spells
.filter((spell) => spell.unlockLevel === 1)
.slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass])
const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass])
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells])
const partyTemplate = useMemo(
() => INITIAL_PARTY.map((member) => ({
...member,
@@ -351,6 +372,12 @@ export function PvpStadiumScreen({
)
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
const rewardDifficulty = rewardDungeon.difficulties[0]
const stadiumCoinBosses = useMemo(
() => profile.dungeons
.flatMap((candidate) => candidate.encounters)
.filter((candidate) => candidate.isBoss && candidate.lootTables.length > 0),
[profile.dungeons],
)
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
partyTemplate,
@@ -382,6 +409,7 @@ export function PvpStadiumScreen({
clearFloatingTexts,
} = useSidedFloatingCombatText()
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
const [stadiumBossCoins, setStadiumBossCoins] = useState<RunBossCoinAward[]>([])
const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false)
const selectedIdRef = useRef(partyTemplate[0].id)
@@ -417,8 +445,8 @@ export function PvpStadiumScreen({
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs],
() => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog),
[buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch],
)
const playerBuffSummary = useMemo(
() => summarizeStacks(playerSide.buffs, buffCatalog),
@@ -473,18 +501,31 @@ export function PvpStadiumScreen({
setStatus('round-countdown')
}, [clearRoundCountdown, startRoundCountdown])
const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => {
const awardXp = useCallback((
key: string,
mode: StadiumExperienceMode,
coinSource?: { encounter: DungeonEncounter; stage: number; label: string },
) => {
if (awardedXpRef.current.has(key)) return
awardedXpRef.current.add(key)
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
bossesCleared: 0,
fightsCleared: 1,
experienceMode: mode,
lootSourceEncounterId: coinSource?.encounter.id,
roguelikeStage: coinSource?.stage,
})
.then((result) => {
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
if (result.bonusItem && coinSource) {
setStadiumBossCoins((current) => [...current, {
...result.bonusItem!,
sourceLabel: coinSource.label,
}])
}
onProfileUpdated(result.profile)
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
if (result.bonusItem) addLog(`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, 'loot')
})
.catch((reason: unknown) => {
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
@@ -521,6 +562,7 @@ export function PvpStadiumScreen({
setLiveMatch(setup.liveMatch)
setPaused(setup.defaults.paused)
setRewardSummary(createEmptyRewardSummary())
setStadiumBossCoins([])
setRewardError(setup.defaults.rewardError)
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
@@ -559,11 +601,13 @@ export function PvpStadiumScreen({
setLiveMatch(null)
setPaused(setup.defaults.paused)
setRewardSummary(createEmptyRewardSummary())
setStadiumBossCoins([])
setRewardError(setup.defaults.rewardError)
setShowEndLog(setup.defaults.showEndLog)
clearFloatingTexts()
loggedOpponentRoundRef.current = ''
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
setCpuDifficulty(randomCpu)
setQueueMessage(message)
setLog([{ id: 1, text: message, tone: 'system' }])
@@ -591,7 +635,7 @@ export function PvpStadiumScreen({
},
},
})
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch])
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameClass, gameMode, partyTemplate, profile.classes, resetShopTimer, setSelectedTargetId, startLiveMatch])
useEffect(() => {
const frame = window.requestAnimationFrame(() => startMatch())
@@ -602,6 +646,7 @@ export function PvpStadiumScreen({
current: StadiumSideState,
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
sideName: 'player' | 'cpu',
spells: Spell[],
spell: Spell,
targetId: string,
) => {
@@ -614,7 +659,7 @@ export function PvpStadiumScreen({
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
})
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
const shieldEffect = spells.find((candidate) => candidate.kind === 'shield')
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
sourcePower
* strength
@@ -717,7 +762,7 @@ export function PvpStadiumScreen({
wasReady: effectiveCost === 0 && current.freeCastReady,
},
})
}, [addFloatingHeal, starterSpells])
}, [addFloatingHeal])
const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || !playerAlive) return
@@ -726,9 +771,9 @@ export function PvpStadiumScreen({
const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next
setPlayerSide(next)
}, 'player', spell, targetId)
}, 'player', starterSpells, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, status])
}, [addLog, applySpell, playerAlive, starterSpells, status])
const getTargetParty = useCallback(() => playerRef.current.party, [])
const {
@@ -749,7 +794,7 @@ export function PvpStadiumScreen({
runCpuHealTurn({
side: cpuRef.current,
elapsedTicks,
spells: starterSpells,
spells: cpuStarterSpells,
behavior,
preferSlots: true,
applySpell: (side, spell, targetId) => {
@@ -757,10 +802,10 @@ export function PvpStadiumScreen({
const next = typeof value === 'function' ? value(cpuRef.current) : value
cpuRef.current = next
setCpuSide(next)
}, 'cpu', spell, targetId)
}, 'cpu', cpuStarterSpells, spell, targetId)
},
})
}, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])
}, [applySpell, cpuDifficulty, cpuStarterSpells, elapsedTicks, status])
const advanceBoss = useCallback((side: StadiumSideState) => {
if (side.roundStatus !== 'playing') return side
@@ -823,7 +868,7 @@ export function PvpStadiumScreen({
})
if (!liveMatchRef.current) {
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
buffCatalog,
cpuBuffCatalog,
stadiumShopPointsForOutcome(outcome, 'opponent'),
)
setCpuSide((current) => {
@@ -832,14 +877,17 @@ export function PvpStadiumScreen({
return next
})
}
}, [buffCatalog, startShopTimer])
}, [cpuBuffCatalog, startShopTimer])
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
if (status !== 'playing') return
if (roundResolvedRef.current) return
roundResolvedRef.current = true
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
awardXp(result.roundExperience.key, result.roundExperience.mode)
const roundCoinSource = stadiumBossCoinSource(stadiumCoinBosses, roundIndex)
awardXp(result.roundExperience.key, result.roundExperience.mode, roundCoinSource
? { encounter: roundCoinSource, stage: roundIndex, label: `Round ${roundIndex}: ${roundCoinSource.enemyName}` }
: undefined)
addLog(result.log.text, result.log.tone)
if (result.status === 'won') {
setRoundWins(result.nextWins)
@@ -849,7 +897,13 @@ export function PvpStadiumScreen({
playerRef.current = next
return next
})
if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode)
if (result.matchExperience) {
const matchCoinStage = roundIndex + 1
const matchCoinSource = stadiumBossCoinSource(stadiumCoinBosses, matchCoinStage)
awardXp(result.matchExperience.key, result.matchExperience.mode, matchCoinSource
? { encounter: matchCoinSource, stage: matchCoinStage, label: `Match Win: ${matchCoinSource.enemyName}` }
: undefined)
}
return
}
if (result.status === 'lost') {
@@ -863,7 +917,7 @@ export function PvpStadiumScreen({
return
}
beginShop(outcome, result.nextWins)
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
}, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, status])
useEffect(() => {
if (status !== 'playing' || paused) return
@@ -1077,12 +1131,12 @@ export function PvpStadiumScreen({
encounterCount: 5,
party: playerSide.party,
opponentName: opponentLabel,
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name),
opponentParty: cpuSide.party,
opponentEnemyHealth: 0,
opponentResource: cpuSide.resource,
opponentMaxResource: MAX_RESOURCE,
opponentResourceName: gameClass.resourceName,
opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName,
opponentBuffSummary,
opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts,
@@ -1110,13 +1164,15 @@ export function PvpStadiumScreen({
activeBindings,
controllerIconStyle,
cpuDifficulty,
cpuGameClass.name,
cpuGameClass.resourceName,
cpuSide.party,
cpuSide.resource,
cpuSide.survivalSeconds,
directPartyTargeting,
dualScreenFloatingTexts,
gameClass.resourceName,
liveMatch?.opponentClassName,
liveMatch,
opponentBuffSummary,
opponentDebuffSummary,
opponentLabel,
@@ -1277,10 +1333,10 @@ export function PvpStadiumScreen({
<div>
<p className="eyebrow">Opponent</p>
<h2>{opponentLabel}</h2>
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
<small>{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}</small>
</div>
<div className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<span>{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
</div>
</div>
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({
>
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
<div className="reward-summary">
<p>Run totals</p>
<RewardXpSummary reward={rewardSummary} />
{rewardError && <p className="reward-error">{rewardError}</p>}
<PvpRunLootList loot={stadiumBossCoins} emptyLabel="No boss coins collected this run." />
</div>
</ResultScreen>
)}
+7 -4
View File
@@ -1,4 +1,5 @@
import type { CombatLogEntry } from '../game'
import type { RunBossCoinAward } from '../combat/rewardSummaries'
import type { DungeonReward, LootRoll } from '../profile'
type BonusItem = NonNullable<DungeonReward['bonusItem']>
@@ -119,14 +120,16 @@ export function LootRollList({
export function PvpRunLootList({
loot,
emptyLabel = 'No boss coins awarded',
}: {
loot: BonusItem[]
loot: RunBossCoinAward[]
emptyLabel?: string
}) {
return (
<div className="run-loot-rolls">
{loot.length > 0 ? loot.map((item, index) => (
<div className="dropped" key={`${item.id}-${index}`}>
<strong>Boss {index + 1}</strong>
<strong>{item.sourceLabel}</strong>
<span>
{item.glyph} {item.name} x{item.quantity}
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
@@ -134,8 +137,8 @@ export function PvpRunLootList({
</div>
)) : (
<div>
<strong>Loot</strong>
<span>No boss loot awarded</span>
<strong>Boss Coins</strong>
<span>{emptyLabel}</span>
</div>
)}
</div>
-6
View File
@@ -1127,9 +1127,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('That difficulty is not available for this dungeon.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
@@ -1251,9 +1248,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
throw new Error('That difficulty is not available for this roguelike.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
+16 -23
View File
@@ -305,7 +305,6 @@ function IWantToHeal1App({
const characterLevel = profile?.character.level ?? 0
const savedTier = tierOptions.find((candidate) => (
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
&& characterLevel >= candidate.unlockLevel
))
if (savedTier) return savedTier
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
@@ -500,7 +499,7 @@ function IWantToHeal1App({
function dungeonEntries() {
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
const locked = !profile || !difficulty
const entries: DungeonNavEntry[] = [
{ kind: 'back' },
]
@@ -522,7 +521,7 @@ function IWantToHeal1App({
entries.push({
kind: 'activity',
index,
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
disabled: !profile || !candidateDifficulty,
})
})
entries.push(
@@ -533,7 +532,7 @@ function IWantToHeal1App({
entries.push({
kind: 'tier',
index,
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
disabled: !profile || !difficultyOption,
})
})
entries.push({ kind: 'loot' })
@@ -592,7 +591,6 @@ function IWantToHeal1App({
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
) ?? candidate.difficulties[0]
: candidate.difficulties[0]
if (profile && profile.character.level < difficulty.unlockLevel) return
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
@@ -601,7 +599,7 @@ function IWantToHeal1App({
function selectTierByIndex(index: number) {
const difficulty = tierOptions[index]
const activity = selectedActivityOption ?? activityOptions[0]
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
if (!difficulty || !activity) return
setActivityPage(0)
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
@@ -622,7 +620,7 @@ function IWantToHeal1App({
function startSelectedRun(marathon: boolean) {
const activity = selectedActivityOption ?? activityOptions[0]
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
if (!activity || !difficulty) return
setSelectedMarathonMode(marathon)
setCombatContentId(activity.id)
setSelectedDifficultyId(difficulty.id)
@@ -787,7 +785,6 @@ function IWantToHeal1App({
|| !selectedActivityOption
|| !selectedDifficultyOption
) return null
const locked = profile.character.level < selectedDifficultyOption.unlockLevel
return {
contentType: selectedActivityOption.contentType,
title: selectedActivityOption.name,
@@ -797,7 +794,7 @@ function IWantToHeal1App({
difficultyName: selectedDifficultyOption.name,
itemLevel: selectedDifficultyOption.droppedItemLevel,
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined,
lockedReason: undefined,
stats: {
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`,
@@ -863,6 +860,7 @@ function IWantToHeal1App({
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
onMainMenu={() => setScreen('menu')}
onProfileUpdated={setProfile}
/>
</Suspense>
@@ -896,6 +894,10 @@ function IWantToHeal1App({
setRoguelikeVariant('pvp')
setScreen('roguelike')
}}
onMainMenu={() => {
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
setScreen('menu')
}}
onProfileUpdated={setProfile}
profile={profile}
/>
@@ -917,7 +919,6 @@ function IWantToHeal1App({
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
const activity = selectedActivityOption ?? dungeon
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
return (
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
{screen !== 'hunter-profile' && (
@@ -1271,13 +1272,11 @@ function IWantToHeal1App({
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const locked = profile.character.level < difficulty.unlockLevel
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
className={`activity-card ${selected ? 'selected' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={locked}
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
@@ -1310,16 +1309,13 @@ function IWantToHeal1App({
<h2>Run</h2>
</div>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: 'Pick a hunt or marathon.'}
Pick a hunt or marathon.
</small>
</div>
<div className="part-picker">
<button
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
@@ -1334,7 +1330,6 @@ function IWantToHeal1App({
<button
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
@@ -1355,17 +1350,15 @@ function IWantToHeal1App({
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers are available at every level.</small>
</div>
<div className="tier-grid">
{tierOptions.map((difficulty, index) => {
const locked = profile.character.level < difficulty.unlockLevel
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
className={`${selected ? 'selected' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
disabled={locked}
key={difficulty.id}
onClick={() => {
setActivityPage(0)
@@ -1389,7 +1382,7 @@ function IWantToHeal1App({
type="button"
>
<strong>iLvl {difficulty.droppedItemLevel}</strong>
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
<span>{difficulty.name}</span>
</button>
)
})}
+171 -37
View File
@@ -15,6 +15,7 @@ import {
Iwt2CloudSaveScreen,
Iwt2CustomizeCharacterScreen,
Iwt2DungeonsScreen,
Iwt2GearUpgradeScreen,
Iwt2HunterProfileScreen,
Iwt2ModeScreen,
Iwt2RoguelikeScreen,
@@ -27,6 +28,13 @@ import {
type Iwt2Save,
} from './save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
import { iwt2BossCoinRewardFor } from './content/bossRewards'
import {
findIwt2Difficulty,
IWT2_DUNGEON_DIFFICULTIES,
IWT2_RAID_DIFFICULTIES,
type Iwt2Difficulty,
} from './content/difficulties'
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
import {
buildIwt2OpponentDebuffChoices,
@@ -48,12 +56,14 @@ type Iwt2Screen =
| 'roguelike'
| 'roguelike-arena'
| 'roguelike-upgrade'
| 'gear-upgrade'
| 'hunter-profile'
| 'customize-character'
| 'settings'
const IWT2_MENU_COLUMNS = 4
const IWT2_MENU_COLUMNS = 2
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
type Iwt2RoguelikeRunState = {
bossIds: Iwt2BossId[]
@@ -72,12 +82,6 @@ const MENU_ITEMS: Array<{
description: string
glyph: string
}> = [
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Save or restore the isolated IWT2 progress slot.',
glyph: 'C',
},
{
screen: 'dungeons',
title: 'Dungeons',
@@ -102,6 +106,12 @@ const MENU_ITEMS: Array<{
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
glyph: 'P',
},
{
screen: 'gear-upgrade',
title: 'Gear Upgrade',
description: 'Spend boss coins on class gear slots and infusion abilities.',
glyph: 'G',
},
{
screen: 'hunter-profile',
title: 'Hunter Profile',
@@ -114,6 +124,12 @@ const MENU_ITEMS: Array<{
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
glyph: 'K',
},
{
screen: 'cloud-save',
title: 'Backup Slot',
description: 'Choose local, online, or fresh IWT2 progress.',
glyph: 'C',
},
{
screen: 'settings',
title: 'Settings',
@@ -122,17 +138,27 @@ const MENU_ITEMS: Array<{
},
]
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
export function IWantToHeal2App({
onlineBackupsAvailable,
onBackToGameSelect,
}: {
onlineBackupsAvailable: boolean
onBackToGameSelect: () => void
}) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [screen, setScreen] = useState<Iwt2Screen>('menu')
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
const [arenaDifficulty, setArenaDifficulty] = useState<Iwt2Difficulty>(IWT2_DUNGEON_DIFFICULTIES[0])
const [selectedDungeonDifficultySlug, setSelectedDungeonDifficultySlug] = useState(IWT2_DUNGEON_DIFFICULTIES[0].slug)
const [selectedRaidDifficultySlug, setSelectedRaidDifficultySlug] = useState(IWT2_RAID_DIFFICULTIES[0].slug)
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
const [pvpQueueing, setPvpQueueing] = useState(false)
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
useEffect(() => {
@@ -143,9 +169,16 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
cancelPvpQueueRef.current?.()
}, [])
function cancelIwt2PvpQueue() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueing(false)
setPvpQueueMessage('')
}
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
[save, screen, selectedBossId],
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)),
[save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug],
)
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
() => buildIwt2WorkshopDualScreenState(screen, save),
@@ -191,7 +224,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
return (
<BossArenaScreen
bossId={selectedBossId}
key={`arena-${selectedBossId}-${arenaModeLabel}`}
difficulty={arenaDifficulty}
key={`arena-${selectedBossId}-${arenaModeLabel}-${arenaDifficulty.slug}`}
modeLabel={arenaModeLabel}
save={save}
onBack={() => setScreen('menu')}
@@ -224,6 +258,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
}}
save={save}
onBack={() => setScreen('roguelike')}
onMainMenu={() => setScreen('menu')}
onPvpRequeue={() => {
setScreen('roguelike')
startIwt2RoguelikeRun()
}}
onSaveUpdated={setSave}
/>
)
@@ -258,12 +297,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2DungeonsScreen
difficultySlug={selectedDungeonDifficultySlug}
save={save}
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Dungeon')
onDifficultyChange={setSelectedDungeonDifficultySlug}
onOpenBoss={(bossId, difficulty) => {
setArenaModeLabel(`${difficulty.name} Dungeon`)
setArenaDifficulty(difficulty)
setSelectedBossId(bossId)
setScreen('arena')
}}
onPreviewBoss={setSelectedBossId}
/>
</main>
)
@@ -276,20 +320,23 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<Iwt2RoguelikeScreen
contentType={roguelikeContentType}
variant={roguelikeVariant}
onBack={() => setScreen('menu')}
onBack={() => {
cancelIwt2PvpQueue()
setScreen('menu')
}}
onCancelQueue={cancelIwt2PvpQueue}
onContentTypeChange={setRoguelikeContentType}
onStart={() => {
startIwt2RoguelikeRun()
}}
onVariantChange={(nextVariant) => {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
cancelIwt2PvpQueue()
setRoguelikeVariant(nextVariant)
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
setRoguelikeContentType('dungeon')
}
}}
queueing={pvpQueueing}
queueMessage={pvpQueueMessage}
/>
</main>
@@ -301,13 +348,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<main className="game-shell iwt2-shell">
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2ModeScreen
difficultySlug={selectedRaidDifficultySlug}
mode="Raids"
save={save}
onBack={() => setScreen('menu')}
onOpenBoss={(bossId) => {
setArenaModeLabel('Raid')
onDifficultyChange={setSelectedRaidDifficultySlug}
onOpenBoss={(bossId, difficulty) => {
setArenaModeLabel(`${difficulty.name} Raid`)
setArenaDifficulty(difficulty)
setSelectedBossId(bossId)
setScreen('arena')
}}
onPreviewBoss={setSelectedBossId}
/>
</main>
)
@@ -321,6 +373,24 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)
}
if (screen === 'gear-upgrade') {
return (
<main className="game-shell iwt2-shell">
<Iwt2Header
save={save}
title="Gear Upgrade"
onBack={() => setScreen('menu')}
onBackToGameSelect={onBackToGameSelect}
/>
<Iwt2GearUpgradeScreen
save={save}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
</main>
)
}
if (screen === 'customize-character') {
return (
<main className="game-shell iwt2-shell">
@@ -340,6 +410,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
<Iwt2CloudSaveScreen
save={save}
onlineBackupsAvailable={onlineBackupsAvailable}
onBack={() => setScreen('menu')}
onSaveUpdated={setSave}
/>
@@ -362,14 +433,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
{screen === 'menu' && (
<section className="iwt2-menu-screen" data-game-nav-active="true">
<div className="iwt2-menu-heading">
<p className="eyebrow">I Want To Heal 2</p>
<h1>Mode Select</h1>
</div>
<div className="iwt2-menu-grid">
{MENU_ITEMS.map((item, index) => (
<button
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
aria-label={`${item.title}. ${item.description}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={`${item.screen}-${item.title}`}
@@ -391,28 +459,33 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
)
function startIwt2RoguelikeRun() {
cancelPvpQueueRef.current?.()
cancelPvpQueueRef.current = null
setPvpQueueMessage('')
cancelIwt2PvpQueue()
if (roguelikeVariant !== 'pvp') {
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
return
}
const startStage = 1
setPvpQueueing(true)
setPvpQueueMessage('Queuing for PvP...')
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
contentType: roguelikeContentType,
startStage,
gameMode: getGameMode(),
liveMatchActive: () => false,
onSearching: setPvpQueueMessage,
onSearching: (message) => {
setPvpQueueing(true)
setPvpQueueMessage(message)
},
onCpuMatch: (_difficulty, message) => {
cancelPvpQueueRef.current = null
setPvpQueueing(false)
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
onLiveMatch: (...liveMatchArgs) => {
const message = liveMatchArgs[2]
cancelPvpQueueRef.current = null
setPvpQueueing(false)
setPvpQueueMessage(message)
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
},
@@ -464,7 +537,14 @@ function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
return {
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
selfChoices: variant === 'pvp'
? chooseRunChoicesWithPreferredFirst(
selfCatalog,
IWT2_ROGUELIKE_CHOICE_COUNT,
isExtraTargetBuff,
IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE,
)
: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
debuffChoices: variant === 'pvp'
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
: [],
@@ -532,23 +612,63 @@ function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
return choices
}
function chooseRunChoicesWithPreferredFirst<T>(
items: readonly T[],
count: number,
preferred: (item: T) => boolean,
preferredChance: number,
): T[] {
if (count <= 0) return []
const pool = [...items]
const choices: T[] = []
const preferredPool = pool.filter(preferred)
if (preferredPool.length > 0 && Math.random() < preferredChance) {
const preferredChoice = preferredPool[Math.floor(Math.random() * preferredPool.length)]
const preferredIndex = pool.indexOf(preferredChoice)
if (preferredIndex >= 0) {
const [choice] = pool.splice(preferredIndex, 1)
if (choice) choices.push(choice)
}
}
while (pool.length > 0 && choices.length < count) {
const index = Math.floor(Math.random() * pool.length)
const [choice] = pool.splice(index, 1)
if (choice) choices.push(choice)
}
return choices
}
function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>) {
return choice.id.endsWith('-extra-target')
}
function Iwt2Header({
onBack,
onBackToGameSelect,
save,
title,
}: {
onBack?: () => void
onBackToGameSelect: () => void
save: Iwt2Save
title?: string
}) {
return (
<header className="topbar app-header">
<button className="brand-button" onClick={onBackToGameSelect} type="button">
<strong>Games</strong>
</button>
{title && <strong className="iwt2-header-title">{title}</strong>}
<div className="character-summary">
<strong>{save.character.name}</strong>
<small>IWT2 Level {save.character.level}</small>
<small>{save.character.experience} XP</small>
</div>
{onBack && (
<button className="back-button iwt2-header-back" data-controller-nav="skip" onClick={onBack} type="button">
Back
</button>
)}
</header>
)
}
@@ -557,30 +677,44 @@ function buildIwt2SetupDualScreenState(
screen: Iwt2Screen,
selectedBossId: Iwt2BossId,
save: Iwt2Save,
difficulty: Iwt2Difficulty,
): DualScreenSetupState | null {
if (screen !== 'dungeons' && screen !== 'raids') return null
const boss = IWT2_BOSS_METADATA[selectedBossId]
const raid = screen === 'raids'
const coinReward = iwt2BossCoinRewardFor(selectedBossId, difficulty.slug)
return {
contentType: raid ? 'raid' : 'dungeon',
description: raid
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
difficultyName: `IWT2 Level ${save.character.level}`,
experience: 125,
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics. Reward: ${coinReward.name}.`
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery. Reward: ${coinReward.name}.`,
difficultyName: difficulty.name,
experience: Math.round(125 * difficulty.experienceMultiplier),
initials: boss.icon,
itemLevel: save.character.level,
itemLevel: difficulty.droppedItemLevel,
lockedReason: undefined,
stats: {
damage: `${boss.meleeDamage}`,
health: `${boss.maxHealth}`,
loot: 'IWT2',
xp: '125',
damage: `${difficulty.damageMultiplier.toFixed(2)}x`,
health: `${difficulty.healthMultiplier.toFixed(2)}x`,
loot: coinReward.name,
xp: `${difficulty.experienceMultiplier.toFixed(1)}x`,
},
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
}
}
function currentSetupDifficulty(
screen: Iwt2Screen,
selectedDungeonDifficultySlug: string,
selectedRaidDifficultySlug: string,
): Iwt2Difficulty {
if (screen === 'raids') {
return findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, selectedRaidDifficultySlug)
}
return findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, selectedDungeonDifficultySlug)
}
function buildIwt2WorkshopDualScreenState(
screen: Iwt2Screen,
save: Iwt2Save,
+1
View File
@@ -42,6 +42,7 @@ export function Iwt2BottomDisplay() {
binding={activeBindings[action]}
iconStyle="playstation"
/>{' '}
<img alt="" aria-hidden="true" className="iwt2-bottom-target-icon" draggable={false} src={target.uiIconUrl} />
{label}
</div>
)
+3 -1
View File
@@ -40,7 +40,9 @@ export function PartyFrames({
/>
</span>
)}
<span style={{ background: meta.color }}>{meta.icon}</span>
<span className="iwt2-party-portrait" style={{ backgroundColor: meta.color }}>
<img alt="" aria-hidden="true" draggable={false} src={meta.uiIconUrl} />
</span>
<div>
<div className="iwt2-party-row-title">
<strong>{meta.name}</strong>
+88 -18
View File
@@ -2,9 +2,12 @@ import type { Iwt2BossId } from './bosses'
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
export type Iwt2BossMaterialReward = {
export type Iwt2BossCoinReward = {
id: string
name: string
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
itemLevel: number
glyph: string
}
export type Iwt2BossPetReward = {
@@ -12,48 +15,115 @@ export type Iwt2BossPetReward = {
name: string
}
const IWT2_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, Iwt2BossMaterialReward> = {
'barroth': { id: 'barroth-shell', name: 'Barroth Shell' },
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' },
type Iwt2CoinTier = {
idPrefix: string
namePrefix: string
rarity: Iwt2BossCoinReward['rarity']
itemLevel: number
glyph: string
}
const IWT2_COIN_TIERS: Record<string, Iwt2CoinTier> = {
initiate: { idPrefix: 'raw', namePrefix: 'Raw', rarity: 'common', itemLevel: 1, glyph: 'R' },
veteran: { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
champion: { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
mythic: { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
ascendant: { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
'raid-normal': { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
'raid-champion': { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
'raid-mythic': { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
'raid-ascendant': { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
}
const IWT2_BOSS_REWARD_DISPLAY_NAMES: Record<Iwt2BossId, string> = {
'barroth': 'Mirehorn',
'bulldrome': 'Bristlemaw',
'cinderback-ricochet': 'Cinderback Ricochet',
'crystal-bat-matriarch': 'Crystal Bat Matriarch',
'ember-mantis-duelist': 'Ember Mantis Duelist',
'great-jaggi': 'Packfang Alpha',
'hollowcrown-revenant': 'Hollowcrown Revenant',
'khezu': 'Palevolt Maw',
'obsidian-ram-golem': 'Obsidian Ram Golem',
'rathian': 'Verdant Wyrm',
'rimebastion': 'Rimebastion',
'sandglass-scorpion': 'Sandglass Scorpion',
'stormcoil-wyrm': 'Stormcoil Wyrm',
'tobi-kadachi': 'Stormtail',
'venom-orchid-hydra': 'Venom Orchid Hydra',
'yian-kut-ku': 'Emberquill',
}
export const IWT2_BOSS_COIN_DIFFICULTY_SLUGS = [
'initiate',
'veteran',
'champion',
'mythic',
'ascendant',
] as const
export const IWT2_LEGACY_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, { id: string, name: string }> = {
'barroth': { id: 'barroth-shell', name: 'Mirehorn Shell' },
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bristlemaw Coin' },
'cinderback-ricochet': { id: 'cinderback-plate', name: 'Cinderback Plate' },
'crystal-bat-matriarch': { id: 'crystal-bat-fang', name: 'Crystal Bat Fang' },
'ember-mantis-duelist': { id: 'ember-mantis-blade', name: 'Ember Mantis Blade' },
'great-jaggi': { id: 'great-jaggi-hide', name: 'Great Jaggi Hide' },
'great-jaggi': { id: 'great-jaggi-hide', name: 'Packfang Alpha Hide' },
'hollowcrown-revenant': { id: 'hollowcrown-antler', name: 'Hollowcrown Antler' },
'khezu': { id: 'khezu-pearl', name: 'Khezu Pearl' },
'khezu': { id: 'khezu-pearl', name: 'Palevolt Maw Pearl' },
'obsidian-ram-golem': { id: 'obsidian-ram-core', name: 'Obsidian Ram Core' },
'rathian': { id: 'rathian-scale', name: 'Rathian Scale' },
'rathian': { id: 'rathian-scale', name: 'Verdant Wyrm Scale' },
'rimebastion': { id: 'rimebastion-core', name: 'Rimebastion Core' },
'sandglass-scorpion': { id: 'sandglass-stinger', name: 'Sandglass Stinger' },
'stormcoil-wyrm': { id: 'stormcoil-conductor', name: 'Stormcoil Conductor' },
'tobi-kadachi': { id: 'tobi-kadachi-pelt', name: 'Tobi Kadachi Pelt' },
'tobi-kadachi': { id: 'tobi-kadachi-pelt', name: 'Stormtail Pelt' },
'venom-orchid-hydra': { id: 'venom-orchid-pod', name: 'Venom Orchid Pod' },
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' },
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Emberquill Scale' },
}
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
'barroth': { id: 'barroth-pet', name: 'Barroth Pet' },
'bulldrome': { id: 'bulldrome-pet', name: 'Bulldrome Pet' },
'barroth': { id: 'barroth-pet', name: 'Mirehorn Pet' },
'bulldrome': { id: 'bulldrome-pet', name: 'Bristlemaw Pet' },
'cinderback-ricochet': { id: 'cinderback-ricochet-pet', name: 'Cinderback Ricochet Pet' },
'crystal-bat-matriarch': { id: 'crystal-bat-matriarch-pet', name: 'Crystal Bat Matriarch Pet' },
'ember-mantis-duelist': { id: 'ember-mantis-duelist-pet', name: 'Ember Mantis Duelist Pet' },
'great-jaggi': { id: 'great-jaggi-pet', name: 'Great Jaggi Pet' },
'great-jaggi': { id: 'great-jaggi-pet', name: 'Packfang Alpha Pet' },
'hollowcrown-revenant': { id: 'hollowcrown-revenant-pet', name: 'Hollowcrown Revenant Pet' },
'khezu': { id: 'khezu-pet', name: 'Khezu Pet' },
'khezu': { id: 'khezu-pet', name: 'Palevolt Maw Pet' },
'obsidian-ram-golem': { id: 'obsidian-ram-golem-pet', name: 'Obsidian Ram Golem Pet' },
'rathian': { id: 'rathian-pet', name: 'Rathian Pet' },
'rathian': { id: 'rathian-pet', name: 'Verdant Wyrm Pet' },
'rimebastion': { id: 'rimebastion-pet', name: 'Rimebastion Pet' },
'sandglass-scorpion': { id: 'sandglass-scorpion-pet', name: 'Sandglass Scorpion Pet' },
'stormcoil-wyrm': { id: 'stormcoil-wyrm-pet', name: 'Stormcoil Wyrm Pet' },
'tobi-kadachi': { id: 'tobi-kadachi-pet', name: 'Tobi Kadachi Pet' },
'tobi-kadachi': { id: 'tobi-kadachi-pet', name: 'Stormtail Pet' },
'venom-orchid-hydra': { id: 'venom-orchid-hydra-pet', name: 'Venom Orchid Hydra Pet' },
'yian-kut-ku': { id: 'yian-kut-ku-pet', name: 'Yian Kut Ku Pet' },
'yian-kut-ku': { id: 'yian-kut-ku-pet', name: 'Emberquill Pet' },
}
export function iwt2BossMaterialRewardFor(bossId: Iwt2BossId): Iwt2BossMaterialReward {
return IWT2_BOSS_MATERIAL_REWARDS[bossId]
export function iwt2BossCoinRewardFor(
bossId: Iwt2BossId,
difficultySlug = 'initiate',
): Iwt2BossCoinReward {
const tier = IWT2_COIN_TIERS[difficultySlug] ?? IWT2_COIN_TIERS.initiate
return {
id: `${tier.idPrefix}-${bossId}-coin`,
name: `${tier.namePrefix} ${formatBossNameForCoin(bossId)} Coin`,
rarity: tier.rarity,
itemLevel: tier.itemLevel,
glyph: tier.glyph,
}
}
export function iwt2BossCoinRewardsFor(bossId: Iwt2BossId): Iwt2BossCoinReward[] {
return IWT2_BOSS_COIN_DIFFICULTY_SLUGS.map((difficultySlug) => (
iwt2BossCoinRewardFor(bossId, difficultySlug)
))
}
export function iwt2BossPetRewardFor(bossId: Iwt2BossId): Iwt2BossPetReward {
return IWT2_BOSS_PET_REWARDS[bossId]
}
function formatBossNameForCoin(bossId: Iwt2BossId): string {
return IWT2_BOSS_REWARD_DISPLAY_NAMES[bossId]
}
+55 -55
View File
@@ -154,7 +154,7 @@ export type Iwt2BossMetadata = {
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
id: 'bulldrome',
name: 'Bulldrome',
name: 'Bristlemaw',
icon: 'B',
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
spriteView: 'side',
@@ -167,26 +167,26 @@ const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
radius: 31,
moveSpeed: 128,
meleeRange: 58,
meleeDamage: 9,
meleeDamage: 20,
meleeCooldown: 1.05,
chargeCooldown: 4.75,
chargeWindup: 0.55,
chargeSpeed: 510,
chargeDamage: 32,
chargeDamage: 60,
chargeStunSeconds: 0.75,
chargeLength: 560,
chargeOvershoot: 120,
chargeWidth: 62,
slamWindup: 0.45,
slamRadius: 104,
slamDamage: 24,
slamDamage: 55,
slamStunSeconds: 0.75,
}
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'yian-kut-ku',
name: 'Yian Kut Ku',
icon: 'Y',
name: 'Emberquill',
icon: 'E',
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
@@ -194,11 +194,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.1,
color: '#d66a35',
accentColor: '#ffd166',
maxHealth: 400,
maxHealth: 750,
radius: 29,
moveSpeed: 118,
meleeRange: 56,
meleeDamage: 8,
meleeDamage: 12,
meleeCooldown: 1,
chargeCooldown: 0,
chargeWindup: 0,
@@ -215,25 +215,25 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
fireballCooldown: 3.6,
fireballWindup: 0.55,
fireballSpeed: 285,
fireballDamage: 12,
fireballDamage: 32,
fireballRadius: 9,
firePuddleRadius: 42,
firePuddleDamage: 7,
firePuddleDamage: 15,
firePuddleSeconds: 8,
birdWaveThresholds: [0.9, 0.4],
birdFlightCooldown: 10,
birdFlightWindup: 0.85,
birdFlightSpeed: 360,
birdHealth: 58,
birdHealth: 100,
birdRadius: 16,
birdContactDamage: 10,
birdContactDamage: 26,
birdStunSeconds: 0.75,
}
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'great-jaggi',
name: 'Great Jaggi',
icon: 'J',
name: 'Packfang Alpha',
icon: 'P',
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
@@ -241,11 +241,11 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.09,
color: '#3f8f73',
accentColor: '#b8f0aa',
maxHealth: 620,
maxHealth: 1000,
radius: 27,
moveSpeed: 146,
meleeRange: 52,
meleeDamage: 7,
meleeDamage: 18,
meleeCooldown: 0.82,
chargeCooldown: 0,
chargeWindup: 0,
@@ -261,7 +261,7 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
slamStunSeconds: 0,
packHowlCooldown: 5.8,
packHowlWindup: 0.95,
packLaneDamage: 15,
packLaneDamage: 45,
packLaneStunSeconds: 0.45,
packLaneWidth: 24,
packLaneCount: 3,
@@ -269,8 +269,8 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
id: 'khezu',
name: 'Khezu',
icon: 'K',
name: 'Palevolt Maw',
icon: 'P',
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
spriteView: 'side',
spriteWidthScale: 5.9,
@@ -278,11 +278,11 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.12,
color: '#d8d7c9',
accentColor: '#77d9ff',
maxHealth: 760,
maxHealth: 1100,
radius: 30,
moveSpeed: 92,
meleeRange: 58,
meleeDamage: 10,
meleeDamage: 20,
meleeCooldown: 1.15,
chargeCooldown: 0,
chargeWindup: 0,
@@ -300,20 +300,20 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
thunderRingWindup: 0.9,
thunderRingInnerRadius: 70,
thunderRingOuterRadius: 172,
thunderRingDamage: 22,
thunderRingDamage: 60,
thunderRingStunSeconds: 0.7,
lightningStrikeCooldown: 4.2,
lightningStrikeWindup: 0.78,
lightningStrikeRadius: 46,
lightningStrikeDamage: 17,
lightningStrikeDamage: 48,
lightningStrikeStunSeconds: 0.55,
lightningStrikeCount: 2,
}
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
id: 'rathian',
name: 'Rathian',
icon: 'R',
name: 'Verdant Wyrm',
icon: 'V',
spriteUrl: '/iwt2/bosses/rathian-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.9,
@@ -325,7 +325,7 @@ const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
radius: 34,
moveSpeed: 118,
meleeRange: 62,
meleeDamage: 11,
meleeDamage: 16,
meleeCooldown: 1.08,
chargeCooldown: 0,
chargeWindup: 0,
@@ -345,19 +345,19 @@ const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
tailSweepInnerRadius: 24,
tailSweepOuterRadius: 118,
tailSweepAngleRadians: Math.PI * 1.25,
tailSweepDamage: 23,
tailSweepDamage: 45,
tailSweepStunSeconds: 0.55,
poisonSpitCooldown: 6.2,
poisonSpitWindup: 0.7,
poisonPuddleRadius: 46,
poisonPuddleDamage: 6,
poisonPuddleDamage: 15,
poisonPuddleSeconds: 8,
}
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
id: 'barroth',
name: 'Barroth',
icon: 'A',
name: 'Mirehorn',
icon: 'M',
spriteUrl: '/iwt2/bosses/barroth-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.3,
@@ -369,7 +369,7 @@ const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
radius: 35,
moveSpeed: 104,
meleeRange: 60,
meleeDamage: 12,
meleeDamage: 16,
meleeCooldown: 1.18,
chargeCooldown: 0,
chargeWindup: 0,
@@ -388,7 +388,7 @@ const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
mudSprayWindup: 0.82,
mudSprayRange: 172,
mudSprayAngleRadians: Math.PI * 0.58,
mudSprayDamage: 13,
mudSprayDamage: 28,
mudSprayStunSeconds: 0.25,
mudPuddleRadius: 40,
mudPuddleSeconds: 7.5,
@@ -396,8 +396,8 @@ const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
id: 'tobi-kadachi',
name: 'Tobi Kadachi',
icon: 'T',
name: 'Stormtail',
icon: 'S',
spriteUrl: '/iwt2/bosses/tobi-kadachi-side-cel.png',
spriteView: 'side',
spriteWidthScale: 6.7,
@@ -409,7 +409,7 @@ const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
radius: 28,
moveSpeed: 160,
meleeRange: 53,
meleeDamage: 8,
meleeDamage: 13,
meleeCooldown: 0.78,
chargeCooldown: 2.2,
chargeWindup: 0,
@@ -428,14 +428,14 @@ const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
staticEnergyPerMelee: 15,
staticPounceWindup: 0.58,
staticPounceSpeed: 560,
staticPounceDamage: 24,
staticPounceDamage: 46,
staticPounceStunSeconds: 0.6,
staticPounceLength: 520,
staticPounceWidth: 46,
chainShockCooldown: 5.2,
chainShockWindup: 0.85,
chainShockRadius: 86,
chainShockDamage: 15,
chainShockDamage: 34,
chainShockStunSeconds: 0.45,
}
@@ -454,7 +454,7 @@ const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
radius: 37,
moveSpeed: 86,
meleeRange: 62,
meleeDamage: 12,
meleeDamage: 16,
meleeCooldown: 1.2,
chargeCooldown: 5.6,
chargeWindup: 0,
@@ -474,8 +474,8 @@ const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
iceWallCount: 3,
iceWallLength: 180,
iceWallWidth: 22,
iceWallDamage: 10,
iceShardDamage: 22,
iceWallDamage: 18,
iceShardDamage: 34,
iceShardStunSeconds: 0.55,
}
@@ -494,7 +494,7 @@ const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
radius: 29,
moveSpeed: 168,
meleeRange: 54,
meleeDamage: 9,
meleeDamage: 13,
meleeCooldown: 0.78,
chargeCooldown: 3.8,
chargeWindup: 0,
@@ -510,7 +510,7 @@ const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
slamStunSeconds: 0,
slashCooldown: 4.2,
slashWindup: 0.48,
slashDamage: 21,
slashDamage: 36,
slashStunSeconds: 0.35,
slashWidth: 24,
crossSlashAngleRadians: Math.PI * 0.32,
@@ -532,7 +532,7 @@ const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
radius: 34,
moveSpeed: 106,
meleeRange: 60,
meleeDamage: 11,
meleeDamage: 14,
meleeCooldown: 1.05,
chargeCooldown: 5.4,
chargeWindup: 0,
@@ -549,15 +549,15 @@ const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
ricochetCooldown: 6.6,
ricochetWindup: 0.65,
ricochetSpeed: 440,
ricochetDamage: 24,
ricochetDamage: 40,
ricochetStunSeconds: 0.45,
ricochetSeconds: 1.45,
lavaTrailRadius: 34,
lavaTrailDamage: 6,
lavaTrailDamage: 15,
lavaTrailSeconds: 5.5,
armorSlamWindup: 0.42,
armorSlamRadius: 86,
armorSlamDamage: 18,
armorSlamDamage: 44,
armorSlamStunSeconds: 0.5,
}
@@ -576,7 +576,7 @@ const DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = {
radius: 36,
moveSpeed: 98,
meleeRange: 62,
meleeDamage: 9,
meleeDamage: 14,
meleeCooldown: 1.12,
chargeCooldown: 5.7,
chargeWindup: 0,
@@ -608,7 +608,7 @@ const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
radius: 30,
moveSpeed: 142,
meleeRange: 58,
meleeDamage: 8,
meleeDamage: 12,
meleeCooldown: 0.95,
chargeCooldown: 5.1,
chargeWindup: 0,
@@ -625,7 +625,7 @@ const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
chainShockCooldown: 5,
chainShockWindup: 0.78,
chainShockRadius: 94,
chainShockDamage: 13,
chainShockDamage: 18,
chainShockStunSeconds: 0.38,
}
@@ -644,7 +644,7 @@ const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
radius: 35,
moveSpeed: 84,
meleeRange: 64,
meleeDamage: 8,
meleeDamage: 12,
meleeCooldown: 1.15,
chargeCooldown: 5.4,
chargeWindup: 0,
@@ -661,7 +661,7 @@ const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
poisonSpitCooldown: 5.6,
poisonSpitWindup: 0.75,
poisonPuddleRadius: 42,
poisonPuddleDamage: 5,
poisonPuddleDamage: 15,
poisonPuddleSeconds: 7,
}
@@ -676,11 +676,11 @@ const DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = {
spriteYOffsetScale: -0.07,
color: '#9d7430',
accentColor: '#ffd15a',
maxHealth: 1180,
maxHealth: 1245,
radius: 33,
moveSpeed: 126,
meleeRange: 58,
meleeDamage: 8,
meleeDamage: 13,
meleeCooldown: 0.98,
chargeCooldown: 5.2,
chargeWindup: 0,
@@ -713,7 +713,7 @@ const DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = {
radius: 31,
moveSpeed: 150,
meleeRange: 56,
meleeDamage: 7,
meleeDamage: 11,
meleeCooldown: 0.9,
chargeCooldown: 5.1,
chargeWindup: 0,
@@ -744,7 +744,7 @@ const DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = {
radius: 30,
moveSpeed: 158,
meleeRange: 56,
meleeDamage: 8,
meleeDamage: 12,
meleeCooldown: 0.9,
chargeCooldown: 4.8,
chargeWindup: 0,
+71
View File
@@ -2,11 +2,48 @@ export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogu
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
export type Iwt2ClassEquipmentSlot =
| 'weapon'
| 'offhand'
| 'head'
| 'chest'
| 'hands'
| 'legs'
export type Iwt2ClassLayerMotion = {
offsetXScale?: number
offsetYScale?: number
rotation?: number
}
export type Iwt2ClassWeaponLayer = {
id: string
slot: Iwt2ClassEquipmentSlot
url: string
drawOrder: 'behindBody' | 'front'
heightScale: number
offsetXScale: number
offsetYScale: number
originX?: number
originY?: number
attackMotion?: Iwt2ClassLayerMotion
tierAccentEligible?: boolean
}
export type Iwt2ClassLayeredArenaSprite = {
bodyUrl: string
bodyHeightScale?: number
weaponLayers: Iwt2ClassWeaponLayer[]
}
export type Iwt2ClassMetadata = {
id: Iwt2PlayerClassId
name: string
role: Iwt2PlayerClassRole
icon: string
arenaSpriteUrl: string
arenaLayeredSprite?: Iwt2ClassLayeredArenaSprite
uiIconUrl: string
color: string
accentColor: string
maxHealth: number
@@ -25,6 +62,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
name: 'Player Healer',
role: 'healer',
icon: '+',
arenaSpriteUrl: '/iwt2/classes/arena/healer-chunky.png',
uiIconUrl: '/iwt2/classes/ui/healer-medallion.png',
color: '#2fbf71',
accentColor: '#b7f7cf',
maxHealth: 125,
@@ -41,6 +80,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
name: 'Paladin Tank',
role: 'tank',
icon: '🛡',
arenaSpriteUrl: '/iwt2/classes/arena/paladin-chunky.png',
uiIconUrl: '/iwt2/classes/ui/paladin-medallion.png',
color: '#f2c94c',
accentColor: '#fff0a8',
maxHealth: 190,
@@ -57,6 +98,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
name: 'Ranger',
role: 'damage',
icon: '🏹',
arenaSpriteUrl: '/iwt2/classes/arena/ranger-chunky.png',
uiIconUrl: '/iwt2/classes/ui/ranger-medallion.png',
color: '#4aa3df',
accentColor: '#b9e3ff',
maxHealth: 115,
@@ -73,6 +116,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
name: 'Mage',
role: 'damage',
icon: '⚚',
arenaSpriteUrl: '/iwt2/classes/arena/mage-chunky.png',
uiIconUrl: '/iwt2/classes/ui/mage-medallion.png',
color: '#c56cf0',
accentColor: '#efc4ff',
maxHealth: 100,
@@ -89,6 +134,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
name: 'Rogue',
role: 'damage',
icon: '††',
arenaSpriteUrl: '/iwt2/classes/arena/rogue-chunky.png',
uiIconUrl: '/iwt2/classes/ui/rogue-medallion.png',
color: '#55efc4',
accentColor: '#c8fff2',
maxHealth: 110,
@@ -105,6 +152,30 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
name: 'Warrior',
role: 'damage',
icon: '⚔',
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
arenaLayeredSprite: {
bodyUrl: '/iwt2/classes/arena/layers/warrior-body.png',
weaponLayers: [
{
id: 'axe',
slot: 'weapon',
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
drawOrder: 'front',
heightScale: 1.72,
offsetXScale: 0.38,
offsetYScale: -0.88,
originX: 0.18,
originY: 0.5,
attackMotion: {
offsetXScale: 0.22,
offsetYScale: -0.18,
rotation: 0.54,
},
tierAccentEligible: true,
},
],
},
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
color: '#ff7675',
accentColor: '#ffd0d0',
maxHealth: 150,
+119
View File
@@ -0,0 +1,119 @@
export type Iwt2Difficulty = {
slug: string
name: string
droppedItemLevel: number
unlockLevel: number
healthMultiplier: number
damageMultiplier: number
experienceMultiplier: number
description: string
}
export const IWT2_DUNGEON_DIFFICULTIES: Iwt2Difficulty[] = [
{
slug: 'initiate',
name: 'Initiate',
droppedItemLevel: 1,
unlockLevel: 1,
healthMultiplier: 0.8,
damageMultiplier: 0.8,
experienceMultiplier: 1,
description: 'IWT1 starter dungeon tuning.',
},
{
slug: 'veteran',
name: 'Veteran',
droppedItemLevel: 10,
unlockLevel: 10,
healthMultiplier: 1.45,
damageMultiplier: 1.25,
experienceMultiplier: 2,
description: 'IWT1 veteran dungeon tuning.',
},
{
slug: 'champion',
name: 'Champion',
droppedItemLevel: 15,
unlockLevel: 15,
healthMultiplier: 1.7,
damageMultiplier: 1.45,
experienceMultiplier: 2.2,
description: 'IWT1 champion dungeon tuning.',
},
{
slug: 'mythic',
name: 'Mythic',
droppedItemLevel: 20,
unlockLevel: 20,
healthMultiplier: 2.25,
damageMultiplier: 1.85,
experienceMultiplier: 3.5,
description: 'IWT1 mythic dungeon tuning.',
},
{
slug: 'ascendant',
name: 'Ascendant',
droppedItemLevel: 25,
unlockLevel: 25,
healthMultiplier: 2.8,
damageMultiplier: 2.25,
experienceMultiplier: 4.5,
description: 'IWT1 ascendant dungeon tuning.',
},
]
export const IWT2_RAID_DIFFICULTIES: Iwt2Difficulty[] = [
{
slug: 'raid-normal',
name: 'Normal',
droppedItemLevel: 10,
unlockLevel: 10,
healthMultiplier: 1.45,
damageMultiplier: 1.25,
experienceMultiplier: 2,
description: 'IWT1 normal raid tuning.',
},
{
slug: 'raid-champion',
name: 'Champion Raid',
droppedItemLevel: 15,
unlockLevel: 15,
healthMultiplier: 1.7,
damageMultiplier: 1.45,
experienceMultiplier: 2.4,
description: 'IWT1 champion raid tuning.',
},
{
slug: 'raid-mythic',
name: 'Mythic Raid',
droppedItemLevel: 20,
unlockLevel: 20,
healthMultiplier: 2.25,
damageMultiplier: 1.85,
experienceMultiplier: 3.5,
description: 'IWT1 mythic raid tuning.',
},
{
slug: 'raid-ascendant',
name: 'Ascendant Raid',
droppedItemLevel: 25,
unlockLevel: 25,
healthMultiplier: 2.8,
damageMultiplier: 2.25,
experienceMultiplier: 4.5,
description: 'IWT1 ascendant raid tuning.',
},
]
export function findIwt2Difficulty(
difficulties: readonly Iwt2Difficulty[],
slug: string,
): Iwt2Difficulty {
return difficulties.find((difficulty) => difficulty.slug === slug) ?? difficulties[0]
}
export function isIwt2DifficultyUnlocked(difficulty: Iwt2Difficulty, level: number): boolean {
void difficulty
void level
return true
}
+199
View File
@@ -0,0 +1,199 @@
import type { Iwt2BossId } from './bosses'
import type { Iwt2PlayerClassId } from './classes'
import { iwt2BossCoinRewardFor } from './bossRewards'
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
export type Iwt2GearStatId =
| 'maxHealth'
| 'moveSpeed'
| 'healingPower'
| 'damage'
| 'attackCooldown'
| 'projectileSpeed'
| 'stunResist'
| 'hazardDamageTaken'
export type Iwt2GearSlotProgress = {
level: Iwt2GearLevel
}
export type Iwt2ClassGearProgress = {
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
infusionAbilityId: Iwt2InfusionAbilityId | null
}
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
export type Iwt2GearUpgradeCost = {
itemId: string
itemName: string
quantity: number
}
export type Iwt2GearSlotRecipe = {
classId: Iwt2PlayerClassId
slotId: Iwt2GearSlotId
primaryBossId: Iwt2BossId
secondaryBossId: Iwt2BossId
statId: Iwt2GearStatId
}
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
weapon: 'Weapon',
helmet: 'Helmet',
chest: 'Chest',
legs: 'Legs',
feet: 'Feet',
}
export const IWT2_GEAR_STAT_LABELS: Record<Iwt2GearStatId, string> = {
maxHealth: 'Max Health',
moveSpeed: 'Move Speed',
healingPower: 'Healing Power',
damage: 'Damage',
attackCooldown: 'Cooldown',
projectileSpeed: 'Projectile Speed',
stunResist: 'Stun Resist',
hazardDamageTaken: 'Hazard Damage Taken',
}
export const IWT2_GEAR_SLOT_RECIPES: Record<Iwt2PlayerClassId, Record<Iwt2GearSlotId, Iwt2GearSlotRecipe>> = {
healer: {
weapon: slotRecipe('healer', 'weapon', 'rathian', 'yian-kut-ku', 'healingPower'),
helmet: slotRecipe('healer', 'helmet', 'khezu', 'rimebastion', 'attackCooldown'),
chest: slotRecipe('healer', 'chest', 'barroth', 'obsidian-ram-golem', 'maxHealth'),
legs: slotRecipe('healer', 'legs', 'tobi-kadachi', 'great-jaggi', 'moveSpeed'),
feet: slotRecipe('healer', 'feet', 'venom-orchid-hydra', 'sandglass-scorpion', 'hazardDamageTaken'),
},
paladin: {
weapon: slotRecipe('paladin', 'weapon', 'obsidian-ram-golem', 'bulldrome', 'damage'),
helmet: slotRecipe('paladin', 'helmet', 'khezu', 'hollowcrown-revenant', 'stunResist'),
chest: slotRecipe('paladin', 'chest', 'barroth', 'rimebastion', 'maxHealth'),
legs: slotRecipe('paladin', 'legs', 'bulldrome', 'tobi-kadachi', 'moveSpeed'),
feet: slotRecipe('paladin', 'feet', 'sandglass-scorpion', 'great-jaggi', 'hazardDamageTaken'),
},
ranger: {
weapon: slotRecipe('ranger', 'weapon', 'cinderback-ricochet', 'yian-kut-ku', 'damage'),
helmet: slotRecipe('ranger', 'helmet', 'crystal-bat-matriarch', 'stormcoil-wyrm', 'attackCooldown'),
chest: slotRecipe('ranger', 'chest', 'great-jaggi', 'barroth', 'maxHealth'),
legs: slotRecipe('ranger', 'legs', 'tobi-kadachi', 'crystal-bat-matriarch', 'moveSpeed'),
feet: slotRecipe('ranger', 'feet', 'sandglass-scorpion', 'venom-orchid-hydra', 'hazardDamageTaken'),
},
mage: {
weapon: slotRecipe('mage', 'weapon', 'stormcoil-wyrm', 'yian-kut-ku', 'damage'),
helmet: slotRecipe('mage', 'helmet', 'rimebastion', 'khezu', 'attackCooldown'),
chest: slotRecipe('mage', 'chest', 'venom-orchid-hydra', 'barroth', 'maxHealth'),
legs: slotRecipe('mage', 'legs', 'tobi-kadachi', 'crystal-bat-matriarch', 'moveSpeed'),
feet: slotRecipe('mage', 'feet', 'hollowcrown-revenant', 'sandglass-scorpion', 'hazardDamageTaken'),
},
rogue: {
weapon: slotRecipe('rogue', 'weapon', 'ember-mantis-duelist', 'rathian', 'damage'),
helmet: slotRecipe('rogue', 'helmet', 'crystal-bat-matriarch', 'khezu', 'attackCooldown'),
chest: slotRecipe('rogue', 'chest', 'great-jaggi', 'barroth', 'maxHealth'),
legs: slotRecipe('rogue', 'legs', 'tobi-kadachi', 'ember-mantis-duelist', 'moveSpeed'),
feet: slotRecipe('rogue', 'feet', 'hollowcrown-revenant', 'sandglass-scorpion', 'hazardDamageTaken'),
},
warrior: {
weapon: slotRecipe('warrior', 'weapon', 'bulldrome', 'ember-mantis-duelist', 'damage'),
helmet: slotRecipe('warrior', 'helmet', 'obsidian-ram-golem', 'khezu', 'attackCooldown'),
chest: slotRecipe('warrior', 'chest', 'barroth', 'rimebastion', 'maxHealth'),
legs: slotRecipe('warrior', 'legs', 'great-jaggi', 'tobi-kadachi', 'moveSpeed'),
feet: slotRecipe('warrior', 'feet', 'rathian', 'venom-orchid-hydra', 'hazardDamageTaken'),
},
}
export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
return {
healer: createDefaultClassGearProgress(),
paladin: createDefaultClassGearProgress(),
ranger: createDefaultClassGearProgress(),
mage: createDefaultClassGearProgress(),
rogue: createDefaultClassGearProgress(),
warrior: createDefaultClassGearProgress(),
}
}
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
return Object.values(progress.slots).some((slot) => slot.level >= 5)
}
export function iwt2GearUpgradeCosts(
classId: Iwt2PlayerClassId,
slotId: Iwt2GearSlotId,
currentLevel: Iwt2GearLevel,
): Iwt2GearUpgradeCost[] {
if (currentLevel >= 5) return []
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
const slug = upgradeDifficultySlug(nextLevel)
const primary = iwt2BossCoinRewardFor(recipe.primaryBossId, slug)
const secondary = iwt2BossCoinRewardFor(recipe.secondaryBossId, slug)
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }]
if (nextLevel === 2) return [
{ itemId: primary.id, itemName: primary.name, quantity: 3 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 1 },
]
if (nextLevel === 3) return [
{ itemId: primary.id, itemName: primary.name, quantity: 3 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 2 },
]
if (nextLevel === 4) return [
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
]
return [
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
]
}
export function iwt2InfusionCosts(
classId: Iwt2PlayerClassId,
slotId: Iwt2GearSlotId,
abilityId: Iwt2InfusionAbilityId,
): Iwt2GearUpgradeCost[] {
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
const ability = IWT2_INFUSION_ABILITIES[abilityId]
const linked = iwt2BossCoinRewardFor(ability.linkedBossId, 'ascendant')
const slotPrimary = iwt2BossCoinRewardFor(recipe.primaryBossId, 'mythic')
return [
{ itemId: linked.id, itemName: linked.name, quantity: 5 },
{ itemId: slotPrimary.id, itemName: slotPrimary.name, quantity: 5 },
]
}
function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
return {
slots: {
weapon: { level: 0 },
helmet: { level: 0 },
chest: { level: 0 },
legs: { level: 0 },
feet: { level: 0 },
},
infusionAbilityId: null,
}
}
function slotRecipe(
classId: Iwt2PlayerClassId,
slotId: Iwt2GearSlotId,
primaryBossId: Iwt2BossId,
secondaryBossId: Iwt2BossId,
statId: Iwt2GearStatId,
): Iwt2GearSlotRecipe {
return { classId, primaryBossId, secondaryBossId, slotId, statId }
}
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
if (level <= 2) return 'initiate'
if (level === 3) return 'veteran'
if (level === 4) return 'champion'
return 'mythic'
}
+15 -5
View File
@@ -1,4 +1,6 @@
import type { Spell } from '../../../game'
import type { Iwt2InfusionAbilityId } from './infusionAbilities'
import { iwt2HealerInfusionAbility } from './healerInfusionAbilities'
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
@@ -23,6 +25,15 @@ export type Iwt2HealerAbility = {
cooldownSeconds: number
target: Iwt2AbilityTarget
extraTargets?: number
triggeredEffects?: Iwt2TriggeredAbilityEffect[]
}
export type Iwt2TriggeredAbilityEffect = {
id: string
name: string
kind: Spell['kind']
power: number
effectType?: string
}
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
@@ -55,7 +66,6 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
createAbility('dawnweaver', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
],
lifebinder: [
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
@@ -63,7 +73,6 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'),
createAbility('lifebinder', 5, 'purging-sap', 'Purging Sap', 'x', 'cleanse', 12, 6, 4.5, 'cleanse'),
createAbility('lifebinder', 6, 'ancient-grove', 'Ancient Grove', 'G', 'shield', 72, 18, 12, 'shield'),
],
runesage: [
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
@@ -71,12 +80,13 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'),
createAbility('runesage', 5, 'unravel', 'Unravel', 'x', 'cleanse', 8, 4, 3.5, 'cleanse'),
createAbility('runesage', 6, 'grand-design', 'Grand Design', 'R', 'group', 22, 13, 8.5),
],
}
export function abilitiesForHealer(healerId: Iwt2HealerId | string) {
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
export function abilitiesForHealer(healerId: Iwt2HealerId | string, infusionAbilityId?: Iwt2InfusionAbilityId | null) {
const base = IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
const infusion = infusionAbilityId ? iwt2HealerInfusionAbility(infusionAbilityId, asIwt2HealerId(healerId)) : null
return infusion ? [...base, infusion] : base
}
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
@@ -0,0 +1,46 @@
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
import type { Iwt2HealerAbility, Iwt2HealerId } from './healerAbilities'
export function iwt2HealerInfusionAbility(
infusionAbilityId: Iwt2InfusionAbilityId,
healerId: Iwt2HealerId,
): Iwt2HealerAbility | null {
const infusion = IWT2_INFUSION_ABILITIES[infusionAbilityId]
if (!infusion || infusion.classId !== 'healer') return null
if (infusion.effectKey === 'sanctuary') {
return createInfusedHealerAbility(healerId, 'sanctuary', infusion.name, infusion.icon, 'shield', 50, 16, infusion.cooldownSeconds ?? 45, 'shield')
}
if (infusion.effectKey === 'guardianPulse') {
return createInfusedHealerAbility(healerId, 'guardian-pulse', infusion.name, infusion.icon, 'group', 26, 14, infusion.cooldownSeconds ?? 40)
}
if (infusion.effectKey === 'emergencyMiracle') {
return createInfusedHealerAbility(healerId, 'emergency-miracle', infusion.name, infusion.icon, 'direct', 90, 20, infusion.cooldownSeconds ?? 75)
}
return null
}
function createInfusedHealerAbility(
healerId: Iwt2HealerId,
slug: string,
name: string,
icon: string,
kind: Iwt2HealerAbility['kind'],
power: number,
manaCost: number,
cooldownSeconds: number,
effectType?: string,
): Iwt2HealerAbility {
return {
cooldownSeconds,
effectType,
healerId,
icon,
id: `${healerId}-infusion-${slug}`,
kind,
manaCost,
name,
power,
slot: 6,
target: 'party-member',
}
}
+121
View File
@@ -0,0 +1,121 @@
import type { Iwt2BossId } from './bosses'
import type { Iwt2PlayerClassId } from './classes'
export type Iwt2InfusionAbilityId =
| 'healer-sanctuary'
| 'healer-guardian-pulse'
| 'healer-emergency-miracle'
| 'paladin-unbreakable'
| 'paladin-bulwark-field'
| 'paladin-intercept'
| 'ranger-decoy-companion'
| 'ranger-pinning-volley'
| 'ranger-hunters-mark'
| 'mage-panic-blink'
| 'mage-arcane-barrier'
| 'mage-overcharge'
| 'rogue-shadow-step'
| 'rogue-vanish'
| 'rogue-expose-weakness'
| 'warrior-bladestorm'
| 'warrior-iron-rush'
| 'warrior-blood-rally'
export type Iwt2InfusionAbilityTrigger =
| 'active'
| 'danger'
| 'passive'
| 'postMechanic'
| 'survival'
export type Iwt2InfusionAbilityEffectKey =
| 'arcaneBarrier'
| 'bladestorm'
| 'bloodRally'
| 'bulwarkField'
| 'decoyCompanion'
| 'emergencyMiracle'
| 'exposeWeakness'
| 'guardianPulse'
| 'huntersMark'
| 'intercept'
| 'ironRush'
| 'overcharge'
| 'panicBlink'
| 'pinningVolley'
| 'sanctuary'
| 'shadowStep'
| 'unbreakable'
| 'vanish'
export type Iwt2InfusionAbility = {
id: Iwt2InfusionAbilityId
classId: Iwt2PlayerClassId
name: string
icon: string
linkedBossId: Iwt2BossId
cooldownSeconds: number | null
description: string
trigger: Iwt2InfusionAbilityTrigger
effectKey: Iwt2InfusionAbilityEffectKey
slot: 6
reuseTags: string[]
}
export const IWT2_INFUSION_ABILITIES: Record<Iwt2InfusionAbilityId, Iwt2InfusionAbility> = {
'healer-sanctuary': infusion('healer-sanctuary', 'healer', 'Sanctuary', 'S', 'rimebastion', 45, 'Place a safe zone that reduces hazard damage for party members inside it.', 'active', 'sanctuary', ['healer', 'hazard-counter', 'zone']),
'healer-guardian-pulse': infusion('healer-guardian-pulse', 'healer', 'Guardian Pulse', 'G', 'khezu', 40, 'Shield the party after a major boss telegraph resolves.', 'postMechanic', 'guardianPulse', ['healer', 'shield', 'telegraph-counter']),
'healer-emergency-miracle': infusion('healer-emergency-miracle', 'healer', 'Emergency Miracle', 'M', 'hollowcrown-revenant', 75, 'Auto-cast a large heal on the lowest-health ally at danger threshold.', 'survival', 'emergencyMiracle', ['healer', 'survival', 'auto-save']),
'paladin-unbreakable': infusion('paladin-unbreakable', 'paladin', 'Unbreakable', 'U', 'bulldrome', null, 'Become immune to stuns and knockdowns.', 'passive', 'unbreakable', ['tank', 'control-immunity']),
'paladin-bulwark-field': infusion('paladin-bulwark-field', 'paladin', 'Bulwark Field', 'B', 'barroth', 35, 'Nearby party members take reduced damage.', 'active', 'bulwarkField', ['tank', 'aura', 'damage-reduction']),
'paladin-intercept': infusion('paladin-intercept', 'paladin', 'Intercept', 'I', 'obsidian-ram-golem', 30, 'Dash to an endangered party member and absorb the next hit.', 'danger', 'intercept', ['tank', 'dash', 'protection']),
'ranger-decoy-companion': infusion('ranger-decoy-companion', 'ranger', 'Decoy Companion', 'D', 'great-jaggi', 60, 'Summon a pet that holds boss attention for 8 seconds.', 'active', 'decoyCompanion', ['ranged', 'summon', 'aggro-decoy']),
'ranger-pinning-volley': infusion('ranger-pinning-volley', 'ranger', 'Pinning Volley', 'P', 'crystal-bat-matriarch', 35, 'Fire a periodic shot that briefly slows the boss.', 'active', 'pinningVolley', ['ranged', 'slow', 'projectile']),
'ranger-hunters-mark': infusion('ranger-hunters-mark', 'ranger', "Hunter's Mark", 'H', 'cinderback-ricochet', 45, 'Mark a boss so ranged hits deal increased damage briefly.', 'active', 'huntersMark', ['ranged', 'debuff', 'damage-window']),
'mage-panic-blink': infusion('mage-panic-blink', 'mage', 'Panic Blink', 'B', 'tobi-kadachi', 45, 'Teleport across the arena when danger scoring says the mage is trapped.', 'danger', 'panicBlink', ['caster', 'teleport', 'escape']),
'mage-arcane-barrier': infusion('mage-arcane-barrier', 'mage', 'Arcane Barrier', 'A', 'rimebastion', 60, 'Gain a shield before a lethal hit.', 'survival', 'arcaneBarrier', ['caster', 'shield', 'survival']),
'mage-overcharge': infusion('mage-overcharge', 'mage', 'Overcharge', 'O', 'stormcoil-wyrm', 40, 'Gain faster casts after avoiding a major mechanic.', 'postMechanic', 'overcharge', ['caster', 'haste', 'telegraph-reward']),
'rogue-shadow-step': infusion('rogue-shadow-step', 'rogue', 'Shadow Step', 'S', 'ember-mantis-duelist', 30, 'Teleport to any party member, then gain 30% damage for 8 seconds.', 'active', 'shadowStep', ['melee', 'teleport', 'burst']),
'rogue-vanish': infusion('rogue-vanish', 'rogue', 'Vanish', 'V', 'hollowcrown-revenant', 45, 'Drop boss focus and evade the next hit.', 'survival', 'vanish', ['melee', 'evade', 'survival']),
'rogue-expose-weakness': infusion('rogue-expose-weakness', 'rogue', 'Expose Weakness', 'E', 'rathian', 35, 'Flanking attacks increase party damage briefly.', 'active', 'exposeWeakness', ['melee', 'debuff', 'flank']),
'warrior-bladestorm': infusion('warrior-bladestorm', 'warrior', 'Bladestorm', 'B', 'bulldrome', 30, 'Spin for 5 seconds, damaging nearby enemies and taking 50% less damage.', 'active', 'bladestorm', ['melee', 'aoe', 'damage-reduction']),
'warrior-iron-rush': infusion('warrior-iron-rush', 'warrior', 'Iron Rush', 'I', 'obsidian-ram-golem', 35, 'Charge through the boss with brief damage reduction.', 'active', 'ironRush', ['melee', 'charge', 'damage-reduction']),
'warrior-blood-rally': infusion('warrior-blood-rally', 'warrior', 'Blood Rally', 'R', 'venom-orchid-hydra', 50, 'Gain damage when party health is low.', 'survival', 'bloodRally', ['melee', 'survival', 'damage-window']),
}
export function iwt2InfusionAbilitiesForClass(classId: Iwt2PlayerClassId): Iwt2InfusionAbility[] {
return Object.values(IWT2_INFUSION_ABILITIES).filter((ability) => ability.classId === classId)
}
export function asIwt2InfusionAbilityId(value: unknown): Iwt2InfusionAbilityId | null {
return typeof value === 'string' && value in IWT2_INFUSION_ABILITIES
? value as Iwt2InfusionAbilityId
: null
}
function infusion(
id: Iwt2InfusionAbilityId,
classId: Iwt2PlayerClassId,
name: string,
icon: string,
linkedBossId: Iwt2BossId,
cooldownSeconds: number | null,
description: string,
trigger: Iwt2InfusionAbilityTrigger,
effectKey: Iwt2InfusionAbilityEffectKey,
reuseTags: string[],
): Iwt2InfusionAbility {
return {
classId,
cooldownSeconds,
description,
effectKey,
icon,
id,
linkedBossId,
name,
reuseTags,
slot: 6,
trigger,
}
}
+179 -1
View File
@@ -11,6 +11,33 @@ export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
export type Iwt2RoguelikeSelfBuffId =
| 'revive-party-members'
| 'dawnweaver-mend-applies-renew'
| 'dawnweaver-mend-applies-sun-ward'
| 'dawnweaver-renew-applies-sun-ward'
| 'dawnweaver-radiance-applies-renew'
| 'dawnweaver-radiance-applies-sun-ward'
| 'dawnweaver-sun-ward-applies-renew'
| 'dawnweaver-sun-ward-damage-reduction'
| 'dawnweaver-purify-applies-renew'
| 'dawnweaver-purify-applies-sun-ward'
| 'lifebinder-verdant-touch-applies-seed-of-life'
| 'lifebinder-verdant-touch-applies-barkskin'
| 'lifebinder-seed-of-life-applies-barkskin'
| 'lifebinder-wild-growth-applies-seed-of-life'
| 'lifebinder-wild-growth-applies-barkskin'
| 'lifebinder-barkskin-applies-seed-of-life'
| 'lifebinder-barkskin-hot-bonus'
| 'lifebinder-purging-sap-applies-seed-of-life'
| 'lifebinder-purging-sap-applies-barkskin'
| 'runesage-etched-mend-applies-mending-rune'
| 'runesage-etched-mend-applies-aegis-script'
| 'runesage-mending-rune-applies-aegis-script'
| 'runesage-concordance-applies-mending-rune'
| 'runesage-concordance-applies-aegis-script'
| 'runesage-aegis-script-applies-mending-rune'
| 'runesage-aegis-script-damage-reduction'
| 'runesage-unravel-applies-mending-rune'
| 'runesage-unravel-applies-aegis-script'
| `slot${Iwt2RoguelikeSlot}-extra-target`
| `slot${Iwt2RoguelikeSlot}-cost-down`
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
@@ -26,6 +53,9 @@ export type Iwt2RoguelikeChoice<T extends string> = {
}
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
export const IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID = 'dawnweaver-sun-ward-damage-reduction'
export const IWT2_BARKSKIN_HOT_BONUS_BUFF_ID = 'lifebinder-barkskin-hot-bonus'
export const IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID = 'runesage-aegis-script-damage-reduction'
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
id: 'revive-party-members',
@@ -33,6 +63,150 @@ export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuff
description: 'Revive fallen party members before the next IWT2 arena.',
}
export const IWT2_DAWNWEAVER_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
{
id: 'dawnweaver-mend-applies-renew',
name: 'Mend Applies Renew',
description: 'Mend also applies Renew to the target.',
},
{
id: 'dawnweaver-mend-applies-sun-ward',
name: 'Mend Applies Sun Ward',
description: 'Mend also applies Sun Ward to the target.',
},
{
id: 'dawnweaver-renew-applies-sun-ward',
name: 'Renew Applies Sun Ward',
description: 'Renew also applies Sun Ward to the target.',
},
{
id: 'dawnweaver-radiance-applies-renew',
name: 'Radiance Applies Renew',
description: 'Radiance also applies Renew to affected allies.',
},
{
id: 'dawnweaver-radiance-applies-sun-ward',
name: 'Radiance Applies 50% Sun Ward',
description: 'Radiance also applies Sun Ward at 50% strength to affected allies.',
},
{
id: 'dawnweaver-sun-ward-applies-renew',
name: 'Sun Ward Applies Renew',
description: 'Sun Ward also applies Renew to the target.',
},
{
id: IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
name: 'Sun Ward Reduces Damage',
description: 'While shielded by Sun Ward, the target takes 50% less damage.',
},
{
id: 'dawnweaver-purify-applies-renew',
name: 'Purify Applies Renew',
description: 'Purify also applies Renew to the target.',
},
{
id: 'dawnweaver-purify-applies-sun-ward',
name: 'Purify Applies Sun Ward',
description: 'Purify also applies Sun Ward to the target.',
},
]
export const IWT2_LIFEBINDER_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
{
id: 'lifebinder-verdant-touch-applies-seed-of-life',
name: 'Verdant Touch Applies Seed of Life',
description: 'Verdant Touch also applies Seed of Life to the target.',
},
{
id: 'lifebinder-verdant-touch-applies-barkskin',
name: 'Verdant Touch Applies 50% Barkskin',
description: 'Verdant Touch also applies Barkskin at 50% strength to the target.',
},
{
id: 'lifebinder-seed-of-life-applies-barkskin',
name: 'Seed of Life Applies Barkskin',
description: 'Seed of Life also applies Barkskin to the target.',
},
{
id: 'lifebinder-wild-growth-applies-seed-of-life',
name: 'Wild Growth Applies Seed of Life',
description: 'Wild Growth also applies Seed of Life to affected allies.',
},
{
id: 'lifebinder-wild-growth-applies-barkskin',
name: 'Wild Growth Applies 50% Barkskin',
description: 'Wild Growth also applies Barkskin at 50% strength to affected allies.',
},
{
id: 'lifebinder-barkskin-applies-seed-of-life',
name: 'Barkskin Applies Seed of Life',
description: 'Barkskin also applies Seed of Life to the target.',
},
{
id: IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
name: 'Barkskin Feeds HoTs',
description: 'While shielded by Barkskin, the target receives 25% more healing over time.',
},
{
id: 'lifebinder-purging-sap-applies-seed-of-life',
name: 'Purging Sap Applies Seed of Life',
description: 'Purging Sap also applies Seed of Life to the target.',
},
{
id: 'lifebinder-purging-sap-applies-barkskin',
name: 'Purging Sap Applies Barkskin',
description: 'Purging Sap also applies Barkskin to the target.',
},
]
export const IWT2_RUNESAGE_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
{
id: 'runesage-etched-mend-applies-mending-rune',
name: 'Etched Mend Applies Mending Rune',
description: 'Etched Mend also applies Mending Rune to the target.',
},
{
id: 'runesage-etched-mend-applies-aegis-script',
name: 'Etched Mend Applies 50% Aegis Script',
description: 'Etched Mend also applies Aegis Script at 50% strength to the target.',
},
{
id: 'runesage-mending-rune-applies-aegis-script',
name: 'Mending Rune Applies Aegis Script',
description: 'Mending Rune also applies Aegis Script to the target.',
},
{
id: 'runesage-concordance-applies-mending-rune',
name: 'Concordance Applies Mending Rune',
description: 'Concordance also applies Mending Rune to affected allies.',
},
{
id: 'runesage-concordance-applies-aegis-script',
name: 'Concordance Applies 50% Aegis Script',
description: 'Concordance also applies Aegis Script at 50% strength to affected allies.',
},
{
id: 'runesage-aegis-script-applies-mending-rune',
name: 'Aegis Script Applies Mending Rune',
description: 'Aegis Script also applies Mending Rune to the target.',
},
{
id: IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
name: 'Aegis Script Reduces Damage',
description: 'While shielded by Aegis Script, the target takes 30% less damage.',
},
{
id: 'runesage-unravel-applies-mending-rune',
name: 'Unravel Applies Mending Rune',
description: 'Unravel also applies Mending Rune to the target.',
},
{
id: 'runesage-unravel-applies-aegis-script',
name: 'Unravel Applies Aegis Script',
description: 'Unravel also applies Aegis Script to the target.',
},
]
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
return {
id: ability.id,
@@ -49,11 +223,15 @@ export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
}
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
const baseChoices = buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
slots: IWT2_ROGUELIKE_SLOTS,
spells: abilities.map(iwt2AbilityToSpell),
labelMode: 'ability',
})
if (abilities.some((ability) => ability.id === 'dawnweaver-mend')) return [...baseChoices, ...IWT2_DAWNWEAVER_SYNERGY_CHOICES]
if (abilities.some((ability) => ability.id === 'lifebinder-verdant-touch')) return [...baseChoices, ...IWT2_LIFEBINDER_SYNERGY_CHOICES]
if (abilities.some((ability) => ability.id === 'runesage-etched-mend')) return [...baseChoices, ...IWT2_RUNESAGE_SYNERGY_CHOICES]
return baseChoices
}
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
+170
View File
@@ -0,0 +1,170 @@
import type { Iwt2BossEntityState } from '../sim'
export type BossRenderMotion = {
x: number
y: number
scaleX: number
scaleY: number
rotation: number
tint?: number
}
type BossMotionProfile = {
idleFrequency: number
idleBob: number
moveFrequency: number
moveBob: number
impactScale: number
}
const DEFAULT_PROFILE: BossMotionProfile = {
idleFrequency: 1.6,
idleBob: 2.1,
moveFrequency: 5.4,
moveBob: 3,
impactScale: 0.035,
}
const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionProfile>> = {
bulldrome: {
idleFrequency: 1.35,
idleBob: 2.3,
moveFrequency: 5.9,
moveBob: 3.2,
impactScale: 0.045,
},
'yian-kut-ku': {
idleFrequency: 2.2,
idleBob: 2.8,
moveFrequency: 6.4,
moveBob: 3.4,
impactScale: 0.032,
},
'obsidian-ram-golem': {
idleFrequency: 1.05,
idleBob: 1.6,
moveFrequency: 3.7,
moveBob: 2.5,
impactScale: 0.052,
},
}
const WARM_TINT = 0xffc88a
const IMPACT_TINT = 0xff9b5f
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion {
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
const phase = String(entity.attackPhase)
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const moving = speed > 8 || phase === 'relocating'
const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: BossRenderMotion = {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
if (isActionPhase(phase, 'recover')) return recoverMotion(motion, profile, entity, timeSeconds)
if (moving) return movingMotion(motion, profile, entity, timeSeconds, facingX)
return idleMotion(motion, profile, timeSeconds)
}
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
return {
...motion,
y: -Math.max(0, wave) * profile.idleBob,
scaleY: 1 + Math.max(0, wave) * 0.025,
scaleX: 1 - Math.max(0, wave) * 0.01,
}
}
function movingMotion(
motion: BossRenderMotion,
profile: BossMotionProfile,
entity: Iwt2BossEntityState,
timeSeconds: number,
facingX: number,
): BossRenderMotion {
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
const lift = Math.abs(stride) * profile.moveBob
return {
...motion,
y: -lift,
scaleX: 1 + 0.012 * speedFactor,
scaleY: 1 - 0.009 * Math.abs(stride),
rotation: facingX * 0.025 * speedFactor,
}
}
function windupMotion(
motion: BossRenderMotion,
entity: Iwt2BossEntityState,
phase: string,
timeSeconds: number,
facingX: number,
): BossRenderMotion {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
return {
...motion,
x: -facingX * (isSlam ? 1.5 : 3),
y: isSlam ? -2 : 2,
scaleX: 1.035,
scaleY: 0.965,
rotation: -facingX * 0.02,
tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined,
}
}
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
return {
...motion,
x: facingX * 3 + shake,
y: Math.cos(timeSeconds * Math.PI * 42) * 1.2,
scaleX: 1.045,
scaleY: 0.96,
rotation: facingX * 0.018,
tint: WARM_TINT,
}
}
function recoverMotion(
motion: BossRenderMotion,
profile: BossMotionProfile,
entity: Iwt2BossEntityState,
timeSeconds: number,
): BossRenderMotion {
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
const pop = profile.impactScale * impactPulse
return {
...motion,
y: -2 * impactPulse,
scaleX: 1 + pop,
scaleY: 1 + pop * 0.45,
tint: impactPulse > 0.45 ? IMPACT_TINT : undefined,
}
}
function isActionPhase(phase: string, suffix: string): boolean {
return phase.toLowerCase().includes(suffix)
}
function isBurstPhase(phase: string): boolean {
const normalized = phase.toLowerCase()
return (
normalized.includes('charging')
|| normalized.includes('swooping')
|| normalized.includes('burrowing')
|| normalized.includes('ricocheting')
|| normalized.includes('flying')
)
}
+124
View File
@@ -0,0 +1,124 @@
import type { Iwt2PartyEntityState } from '../sim'
import type { Iwt2ClassWeaponLayer } from '../content/classes'
export type PartyAttackPulse = {
startedAt: number
}
export type PartyRenderMotion = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
tint?: number
}
export type PartyWeaponLayerMotion = {
offsetXScale: number
offsetYScale: number
rotation: number
}
const ATTACK_DURATION_SECONDS = 0.22
const RANGED_ATTACK_DURATION_SECONDS = 0.16
export function partyRenderMotion(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
): PartyRenderMotion {
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
const facingX = entity.facing.x < -0.05 ? -1 : 1
const motion: PartyRenderMotion = {
x: 0,
y: 0,
rotation: 0,
scaleX: 1,
scaleY: 1,
}
if (entity.health <= 0) return { ...motion, rotation: facingX * 0.08, scaleY: 0.92 }
if (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) {
return { ...motion, y: 3, rotation: -facingX * 0.12, scaleY: 0.9 }
}
const attackProgress = attackPulse ? attackProgressFor(entity, timeSeconds, attackPulse) : 1
if (attackProgress < 1) {
const strike = Math.sin(attackProgress * Math.PI)
const ranged = entity.projectileSpeed > 0
return {
...motion,
x: facingX * (ranged ? -3 : 4) * strike,
y: -1.5 * strike,
rotation: facingX * (ranged ? -0.05 : 0.09) * strike,
scaleX: 1 + (ranged ? 0.012 : 0.035) * strike,
scaleY: 1 - (ranged ? 0.006 : 0.018) * strike,
tint: ranged ? 0xdff3ff : 0xffe1a6,
}
}
if (entity.castSecondsRemaining > 0) {
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 12) * 0.5
return {
...motion,
y: -1 - pulse * 1.5,
scaleX: 1 + pulse * 0.012,
scaleY: 1 + pulse * 0.012,
tint: entity.classId === 'mage' ? 0xefc4ff : 0xb9e3ff,
}
}
if (speed > 10) {
const stride = Math.sin(timeSeconds * Math.PI * 9)
const lift = Math.abs(stride) * Math.min(2.8, speed / 90)
return {
...motion,
y: -lift,
rotation: facingX * 0.025 * Math.min(1, speed / 190),
scaleY: 1 - Math.abs(stride) * 0.01,
}
}
const idle = Math.sin(timeSeconds * Math.PI * 2.2)
return {
...motion,
y: -Math.max(0, idle) * 0.9,
scaleY: 1 + Math.max(0, idle) * 0.008,
}
}
export function isPartyAttackPulseActive(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse: PartyAttackPulse,
): boolean {
return attackProgressFor(entity, timeSeconds, attackPulse) < 1
}
export function partyWeaponLayerMotion(
entity: Iwt2PartyEntityState,
layer: Iwt2ClassWeaponLayer,
timeSeconds: number,
attackPulse?: PartyAttackPulse,
): PartyWeaponLayerMotion {
if (!attackPulse || entity.health <= 0) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
const progress = attackProgressFor(entity, timeSeconds, attackPulse)
if (progress >= 1) return { offsetXScale: 0, offsetYScale: 0, rotation: 0 }
const strike = Math.sin(progress * Math.PI)
const attackMotion = layer.attackMotion
return {
offsetXScale: (attackMotion?.offsetXScale ?? 0) * strike,
offsetYScale: (attackMotion?.offsetYScale ?? 0) * strike,
rotation: (attackMotion?.rotation ?? 0) * strike,
}
}
function attackProgressFor(
entity: Iwt2PartyEntityState,
timeSeconds: number,
attackPulse: PartyAttackPulse,
): number {
const duration = entity.projectileSpeed > 0 ? RANGED_ATTACK_DURATION_SECONDS : ATTACK_DURATION_SECONDS
return Math.max(0, Math.min(1, (timeSeconds - attackPulse.startedAt) / duration))
}
@@ -1,8 +1,16 @@
import Phaser from 'phaser'
import type { MovementVector } from '../../../../input'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
import { IWT2_CLASS_METADATA } from '../../content/classes'
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
import { bossRenderMotion } from '../bossAnimation'
import {
isPartyAttackPulseActive,
partyRenderMotion,
partyWeaponLayerMotion,
type PartyAttackPulse,
} from '../partyAnimation'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
@@ -20,6 +28,16 @@ type SceneDeps = {
}
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
type FloatingCombatTextAnchor = {
kind: DrawableEntity['kind']
position: Iwt2Vec2
radius: number
}
type PartyLayerSprites = {
body: Phaser.GameObjects.Image
weapons: Map<string, Phaser.GameObjects.Image>
accents: Map<string, Phaser.GameObjects.Image>
}
export class BulldromeArenaScene extends Phaser.Scene {
private deps: SceneDeps
@@ -29,6 +47,17 @@ export class BulldromeArenaScene extends Phaser.Scene {
private telegraphGraphics?: Phaser.GameObjects.Graphics
private labels = new Map<string, Phaser.GameObjects.Text>()
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
private partySprites = new Map<string, Phaser.GameObjects.Image>()
private partyLayerSprites = new Map<string, PartyLayerSprites>()
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
private partyAttackPulses = new Map<string, PartyAttackPulse>()
private livePartyEffectIds = new Set<string>()
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
private lastPartyAnimationEventId = 0
private lastFloatingEventId = 0
private lastStateTime = 0
private lastHudPublish = 0
constructor(deps: SceneDeps) {
@@ -44,6 +73,17 @@ export class BulldromeArenaScene extends Phaser.Scene {
this.load.image(bossSpriteKey(metadata.id), metadata.spriteUrl)
}
}
for (const metadata of Object.values(IWT2_CLASS_METADATA)) {
this.load.image(classArenaSpriteKey(metadata.id), metadata.arenaSpriteUrl)
if (metadata.arenaLayeredSprite) {
this.load.image(classArenaBodySpriteKey(metadata.id), metadata.arenaLayeredSprite.bodyUrl)
for (const layer of metadata.arenaLayeredSprite.weaponLayers) {
this.load.image(classArenaLayerSpriteKey(metadata.id, layer.id), layer.url)
}
}
}
this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png')
this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png')
}
create() {
@@ -64,8 +104,10 @@ export class BulldromeArenaScene extends Phaser.Scene {
if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return
this.drawArena(state)
this.drawTelegraphs(state)
this.updatePartyAnimations(state)
this.drawEntities(state)
this.drawProjectiles(state)
this.drawFloatingCombatTexts(state)
this.lastHudPublish += 1
}
@@ -99,8 +141,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.clear()
for (const hazard of state.hazards) drawHazard(graphics, hazard)
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
const liveLabelIds = new Set<string>(entities.filter((entity) => entity.kind !== 'boss').map((entity) => entity.id))
const liveLabelIds = new Set<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id))
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
const livePartyIds = new Set<string>(state.party.map((member) => member.id))
for (const entity of entities.sort(entitySort)) {
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
@@ -109,7 +152,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
if (entity.kind === 'boss') {
if (this.textures.exists(bossSpriteKey(entity.bossId))) {
drawBossUnderlay(bossUnderlay, entity, color, alpha)
this.drawBossSprite(entity, alpha)
this.drawBossSprite(entity, alpha, state.time)
} else {
drawBossFallback(graphics, entity, color, alpha)
}
@@ -134,16 +177,25 @@ export class BulldromeArenaScene extends Phaser.Scene {
entity.position.y + entity.radius,
)
} else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.lineStyle(entity.aiRole === 'player' ? 4 : 2, 0x0a0c10, 1)
if (entity.id === this.deps.getSelectedPartyId()) {
graphics.lineStyle(4, 0xfff4a8, 0.95)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
}
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
const metadata = IWT2_CLASS_METADATA[entity.classId]
if (metadata.arenaLayeredSprite && this.textures.exists(classArenaBodySpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawLayeredPartySprite(entity, alpha, state.time)
} else if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
drawPartyUnderlay(graphics, entity, color, alpha)
this.drawPartySprite(entity, alpha, state.time)
} else {
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
}
if (entity.kind !== 'boss') this.drawLabel(entity)
if (shouldDrawLabel(entity)) this.drawLabel(entity)
this.drawHealthBar(graphics, entity)
}
@@ -154,6 +206,22 @@ export class BulldromeArenaScene extends Phaser.Scene {
}
}
for (const [id, sprite] of this.partySprites) {
if (!livePartyIds.has(id)) {
sprite.destroy()
this.partySprites.delete(id)
}
}
for (const [id, sprites] of this.partyLayerSprites) {
if (!livePartyIds.has(id)) {
sprites.body.destroy()
for (const sprite of sprites.weapons.values()) sprite.destroy()
for (const sprite of sprites.accents.values()) sprite.destroy()
this.partyLayerSprites.delete(id)
}
}
for (const [id, label] of this.labels) {
if (!liveLabelIds.has(id)) {
label.destroy()
@@ -164,16 +232,40 @@ export class BulldromeArenaScene extends Phaser.Scene {
private drawProjectiles(state: Iwt2ArenaState) {
const graphics = this.entityGraphics!
const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id))
for (const projectile of state.projectiles) {
const spriteKey = projectileSpriteKey(projectile.projectileKind)
if (this.textures.exists(spriteKey)) {
let sprite = this.projectileSprites.get(projectile.id)
if (!sprite) {
sprite = this.add.image(projectile.position.x, projectile.position.y, spriteKey).setOrigin(0.5).setDepth(45)
this.projectileSprites.set(projectile.id, sprite)
}
const velocityAngle = Math.atan2(projectile.velocity.y, projectile.velocity.x)
const size = projectileDisplaySize(projectile.projectileKind, projectile.radius)
sprite
.setTexture(spriteKey)
.setPosition(projectile.position.x, projectile.position.y)
.setRotation(velocityAngle)
.setDisplaySize(size.width, size.height)
.setAlpha(0.95)
continue
}
const color = Number.parseInt(projectile.color.slice(1), 16)
graphics.fillStyle(color, 0.95)
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
}
for (const [id, sprite] of this.projectileSprites) {
if (!liveProjectileIds.has(id)) {
sprite.destroy()
this.projectileSprites.delete(id)
}
}
}
private drawBossSprite(entity: Iwt2BossEntityState, alpha: number) {
private drawBossSprite(entity: Iwt2BossEntityState, alpha: number, timeSeconds: number) {
const key = bossSpriteKey(entity.bossId)
const metadata = IWT2_BOSS_METADATA[entity.bossId]
let sprite = this.bossSprites.get(entity.id)
@@ -188,18 +280,221 @@ export class BulldromeArenaScene extends Phaser.Scene {
const width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
const motion = bossRenderMotion(entity, timeSeconds)
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite
.setTexture(key)
.setPosition(entity.position.x, entity.position.y + yOffset)
.setDisplaySize(width, height)
.setRotation(rotation)
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
.setDisplaySize(width * motion.scaleX, height * motion.scaleY)
.setRotation(rotation + motion.rotation)
.setFlipX(isSideView && entity.facing.x < -0.05)
.setAlpha(alpha)
}
private drawLayeredPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const metadata = IWT2_CLASS_METADATA[entity.classId].arenaLayeredSprite
if (!metadata) return
let sprites = this.partyLayerSprites.get(entity.id)
if (!sprites) {
sprites = {
body: this.add.image(entity.position.x, entity.position.y, classArenaBodySpriteKey(entity.classId)).setOrigin(0.5, 0.66).setDepth(40),
weapons: new Map<string, Phaser.GameObjects.Image>(),
accents: new Map<string, Phaser.GameObjects.Image>(),
}
this.partyLayerSprites.set(entity.id, sprites)
}
const fallback = this.partySprites.get(entity.id)
if (fallback) {
fallback.destroy()
this.partySprites.delete(entity.id)
}
const baseHeight = entity.radius * (metadata.bodyHeightScale ?? 4.55)
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
applyMotionTint(sprites.body, motion.tint)
sprites.body
.setTexture(classArenaBodySpriteKey(entity.classId))
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(baseHeight * (sprites.body.width / Math.max(1, sprites.body.height)) * motion.scaleX, baseHeight * motion.scaleY)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
const liveLayerIds = new Set<string>()
for (const layer of metadata.weaponLayers) {
liveLayerIds.add(layer.id)
let sprite = sprites.weapons.get(layer.id)
const key = classArenaLayerSpriteKey(entity.classId, layer.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.weapons.set(layer.id, sprite)
}
const flipX = entity.facing.x < -0.05
const facingSign = flipX ? -1 : 1
const layerMotion = partyWeaponLayerMotion(entity, layer, timeSeconds, this.partyAttackPulses.get(entity.id))
const layerHeight = entity.radius * layer.heightScale
const positionX = entity.position.x + motion.x + facingSign * entity.radius * (layer.offsetXScale + layerMotion.offsetXScale)
const positionY = entity.position.y + entity.radius * 0.18 + motion.y + entity.radius * (layer.offsetYScale + layerMotion.offsetYScale)
const displayWidth = layerHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX
const displayHeight = layerHeight * motion.scaleY
const rotation = motion.rotation + facingSign * layerMotion.rotation
this.drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId: layer.id,
positionX,
positionY,
rotation,
sprites,
})
applyMotionTint(sprite, motion.tint)
sprite
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 38 : 42)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth, displayHeight)
.setRotation(rotation)
.setFlipX(flipX)
.setAlpha(alpha)
}
for (const [id, sprite] of sprites.weapons) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.weapons.delete(id)
}
}
for (const [id, sprite] of sprites.accents) {
if (!liveLayerIds.has(id)) {
sprite.destroy()
sprites.accents.delete(id)
}
}
}
private drawGearTierAccent({
alpha,
displayHeight,
displayWidth,
entity,
flipX,
key,
layer,
layerId,
positionX,
positionY,
rotation,
sprites,
}: {
alpha: number
displayHeight: number
displayWidth: number
entity: Iwt2PartyEntityState
flipX: boolean
key: string
layer: NonNullable<(typeof IWT2_CLASS_METADATA)[Iwt2PlayerClassId]['arenaLayeredSprite']>['weaponLayers'][number]
layerId: string
positionX: number
positionY: number
rotation: number
sprites: PartyLayerSprites
}) {
const tierColor = gearTierAccentColor(gearLayerLevel(entity, layer.slot))
let accent = sprites.accents.get(layerId)
if (!layer.tierAccentEligible || !tierColor) {
if (accent) accent.setVisible(false)
return
}
if (!accent) {
accent = this.add.image(positionX, positionY, key).setOrigin(layer.originX ?? 0.5, layer.originY ?? 0.5)
sprites.accents.set(layerId, accent)
}
accent
.setVisible(true)
.setTexture(key)
.setDepth(layer.drawOrder === 'behindBody' ? 37 : 41)
.setPosition(positionX, positionY)
.setDisplaySize(displayWidth * 1.08, displayHeight * 1.12)
.setRotation(rotation)
.setFlipX(flipX)
.setTint(tierColor)
.setAlpha(alpha * 0.82)
}
private drawPartySprite(entity: Iwt2PartyEntityState, alpha: number, timeSeconds: number) {
const key = classArenaSpriteKey(entity.classId)
let sprite = this.partySprites.get(entity.id)
if (!sprite) {
sprite = this.add.image(entity.position.x, entity.position.y, key).setOrigin(0.5, 0.66).setDepth(40)
this.partySprites.set(entity.id, sprite)
}
const layered = this.partyLayerSprites.get(entity.id)
if (layered) {
layered.body.destroy()
for (const layerSprite of layered.weapons.values()) layerSprite.destroy()
for (const layerSprite of layered.accents.values()) layerSprite.destroy()
this.partyLayerSprites.delete(entity.id)
}
const displayHeight = entity.radius * 4.55
const motion = partyRenderMotion(entity, timeSeconds, this.partyAttackPulses.get(entity.id))
if (motion.tint) {
sprite.setTint(motion.tint)
} else {
sprite.clearTint()
}
sprite
.setTexture(key)
.setPosition(entity.position.x + motion.x, entity.position.y + entity.radius * 0.18 + motion.y)
.setDisplaySize(
displayHeight * (sprite.width / Math.max(1, sprite.height)) * motion.scaleX,
displayHeight * motion.scaleY,
)
.setRotation(motion.rotation)
.setFlipX(entity.facing.x < -0.05)
.setAlpha(alpha)
}
private updatePartyAnimations(state: Iwt2ArenaState) {
if (state.time < this.lastStateTime) {
this.lastPartyAnimationEventId = 0
this.partyAttackPulses.clear()
}
this.livePartyEffectIds.clear()
for (const member of state.party) this.livePartyEffectIds.add(member.id)
for (const event of state.events) {
if (event.id <= this.lastPartyAnimationEventId) continue
if (event.type === 'partyAttack' && this.livePartyEffectIds.has(event.sourceId)) {
this.partyAttackPulses.set(event.sourceId, { startedAt: event.time })
}
this.lastPartyAnimationEventId = Math.max(this.lastPartyAnimationEventId, event.id)
}
for (const member of state.party) {
const attackPulse = this.partyAttackPulses.get(member.id)
if (attackPulse && !isPartyAttackPulseActive(member, state.time, attackPulse)) {
this.partyAttackPulses.delete(member.id)
}
}
for (const id of this.partyAttackPulses.keys()) {
if (!this.livePartyEffectIds.has(id)) this.partyAttackPulses.delete(id)
}
}
private drawLabel(entity: DrawableEntity) {
let label = this.labels.get(entity.id)
const icon = entityIcon(entity)
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
if (!label) {
label = this.add.text(entity.position.x, entity.position.y, icon, {
align: 'center',
@@ -210,9 +505,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
}).setOrigin(0.5)
this.labels.set(entity.id, label)
}
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
label.setText(stunned ? '!' : icon)
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
label.setText(icon)
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'party' ? entity.radius * 2.1 : 0))
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
}
@@ -245,6 +539,95 @@ export class BulldromeArenaScene extends Phaser.Scene {
graphics.fillRect(entity.position.x - width / 2, y + height + 8, width * energyRatio, 4)
}
}
private drawFloatingCombatTexts(state: Iwt2ArenaState) {
if (state.time < this.lastStateTime) {
this.lastFloatingEventId = 0
for (const text of this.floatingCombatTexts.values()) text.destroy()
this.floatingCombatTexts.clear()
for (const glow of this.castGlowEffects) glow.destroy()
this.castGlowEffects.clear()
this.lastEntityAnchors.clear()
}
for (const event of state.events) {
if (event.id <= this.lastFloatingEventId) continue
if (event.type === 'healerSpellCast') this.spawnHealerCastGlow(state)
if (!shouldSpawnFloatingCombatText(event)) {
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
continue
}
this.spawnFloatingCombatText(event, state)
this.lastFloatingEventId = Math.max(this.lastFloatingEventId, event.id)
}
this.lastStateTime = state.time
this.lastEntityAnchors = entityAnchors(state)
}
private spawnFloatingCombatText(event: Iwt2ArenaEvent, state: Iwt2ArenaState) {
const anchor = event.targetId
? entityAnchors(state).get(event.targetId) ?? this.lastEntityAnchors.get(event.targetId)
: undefined
if (!anchor || !event.value || event.value <= 0) return
const value = Math.round(event.value)
const isHeal = event.type === 'partyHealed'
const jitter = ((event.id % 5) - 2) * 9
const x = anchor.position.x + jitter
const y = anchor.position.y - floatingTextYOffset(anchor)
const text = this.add.text(x, y, `${isHeal ? '+' : ''}${value}`, {
align: 'center',
color: isHeal ? '#73f2a6' : '#ffd25f',
fontFamily: 'ui-monospace, Consolas, monospace',
fontSize: anchor.kind === 'boss' ? '24px' : '19px',
fontStyle: '900',
stroke: '#050608',
strokeThickness: 5,
}).setOrigin(0.5).setDepth(75)
this.floatingCombatTexts.set(event.id, text)
this.tweens.add({
targets: text,
alpha: 0,
y: y - (anchor.kind === 'boss' ? 48 : 34),
scale: anchor.kind === 'boss' ? 1.18 : 1.08,
duration: 850,
ease: 'Cubic.easeOut',
onComplete: () => {
text.destroy()
this.floatingCombatTexts.delete(event.id)
},
})
}
private spawnHealerCastGlow(state: Iwt2ArenaState) {
const healer = state.party.find((member) => member.id === 'player-healer' && member.health > 0)
if (!healer) return
const staffHead = staffHeadPosition(healer)
const glow = this.add.graphics().setDepth(65)
glow.setPosition(staffHead.x, staffHead.y)
glow.fillStyle(0x9fffe0, 0.28)
glow.fillCircle(0, 0, healer.radius * 1.2)
glow.lineStyle(3, 0xf8f5a6, 0.9)
glow.strokeCircle(0, 0, healer.radius * 0.9)
glow.lineStyle(2, 0xb7f7cf, 0.7)
glow.strokeCircle(0, 0, healer.radius * 1.45)
this.castGlowEffects.add(glow)
this.tweens.add({
targets: glow,
alpha: 0,
scale: 1.55,
duration: 360,
ease: 'Cubic.easeOut',
onComplete: () => {
glow.destroy()
this.castGlowEffects.delete(glow)
},
})
}
}
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
@@ -264,10 +647,108 @@ function entityIcon(entity: DrawableEntity): string {
return IWT2_CLASS_METADATA[entity.classId].icon
}
function applyMotionTint(sprite: Phaser.GameObjects.Image, tint?: number) {
if (tint) {
sprite.setTint(tint)
} else {
sprite.clearTint()
}
}
function gearLayerLevel(entity: Iwt2PartyEntityState, slot: string): number {
if (slot === 'weapon') return entity.gearLevels?.weapon ?? 0
if (slot === 'head') return entity.gearLevels?.helmet ?? 0
if (slot === 'chest') return entity.gearLevels?.chest ?? 0
if (slot === 'legs') return entity.gearLevels?.legs ?? 0
return 0
}
function gearTierAccentColor(level: number): number | undefined {
if (level <= 0) return undefined
if (level <= 2) return 0x55e887
if (level === 3) return 0x58a8ff
if (level === 4) return 0xc56cf0
return 0xffd25f
}
function bossSpriteKey(bossId: Iwt2BossId): string {
return `iwt2-boss-${bossId}`
}
function classArenaSpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}`
}
function classArenaBodySpriteKey(classId: Iwt2PlayerClassId): string {
return `iwt2-class-${classId}-body`
}
function classArenaLayerSpriteKey(classId: Iwt2PlayerClassId, layerId: string): string {
return `iwt2-class-${classId}-${layerId}`
}
function projectileSpriteKey(projectileKind: 'arrow' | 'fireball' | 'magic'): string {
return projectileKind === 'arrow' ? 'iwt2-projectile-arrow' : 'iwt2-projectile-fireball'
}
function projectileDisplaySize(projectileKind: 'arrow' | 'fireball' | 'magic', radius: number): { width: number; height: number } {
if (projectileKind === 'arrow') {
return { width: radius * 11, height: radius * 1.9 }
}
return { width: radius * 5.4, height: radius * 3.15 }
}
function shouldDrawLabel(entity: DrawableEntity): boolean {
if (entity.kind === 'hostileAdd') return true
return entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
}
function shouldSpawnFloatingCombatText(event: Iwt2ArenaEvent): boolean {
return (event.type === 'partyHealed' || event.type === 'bossDamaged')
&& event.value !== undefined
&& event.value > 0
&& event.targetId !== undefined
}
function entityAnchors(state: Iwt2ArenaState): Map<string, FloatingCombatTextAnchor> {
const anchors = new Map<string, FloatingCombatTextAnchor>()
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) {
anchors.set(entity.id, {
kind: entity.kind,
position: { ...entity.position },
radius: entity.radius,
})
}
return anchors
}
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
if (anchor.kind === 'boss') return anchor.radius * 1.45
if (anchor.kind === 'hostileAdd') return anchor.radius * 1.8
return anchor.radius * 2.85
}
function staffHeadPosition(entity: Iwt2PartyEntityState): Iwt2Vec2 {
const facingSign = entity.facing.x < -0.05 ? -1 : 1
return {
x: entity.position.x + facingSign * entity.radius * 1.45,
y: entity.position.y - entity.radius * 2.15,
}
}
function drawPartyUnderlay(
graphics: Phaser.GameObjects.Graphics,
entity: Iwt2PartyEntityState,
color: string,
alpha: number,
) {
const colorValue = Number.parseInt(color.slice(1), 16)
graphics.fillStyle(0x050608, 0.38 * alpha)
graphics.fillEllipse(entity.position.x, entity.position.y + entity.radius * 0.8, entity.radius * 2.25, entity.radius * 0.72)
graphics.lineStyle(entity.aiRole === 'player' ? 3 : 2, colorValue, 0.58 * alpha)
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
}
function drawBossUnderlay(
graphics: Phaser.GameObjects.Graphics,
entity: Iwt2BossEntityState,
+301 -26
View File
@@ -1,12 +1,36 @@
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
import { IWT2_BOSS_PET_DROP_RATE, iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
import { coinDropQuantity } from '../../../shared/rewardRules.mjs'
import { requestGameApiJson } from '../../../gameRepository'
import {
IWT2_BOSS_PET_DROP_RATE,
IWT2_LEGACY_BOSS_MATERIAL_REWARDS,
iwt2BossCoinRewardFor,
iwt2BossPetRewardFor,
} from '../content/bossRewards'
import type { Iwt2BossId } from '../content/bosses'
import {
createDefaultIwt2GearProgress,
IWT2_GEAR_SLOTS,
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
type Iwt2GearLevel,
type Iwt2GearProgress,
type Iwt2GearSlotId,
} from '../content/gear'
import {
IWT2_INFUSION_ABILITIES,
iwt2InfusionAbilitiesForClass,
type Iwt2InfusionAbilityId,
} from '../content/infusionAbilities'
import { IWT2_PARTY_ORDER, type Iwt2PlayerClassId } from '../content/classes'
export type Iwt2InventoryItem = {
id: string
name: string
quantity: number
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
itemLevel: number
}
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
@@ -30,6 +54,7 @@ export type Iwt2CloudSlot = {
character: Iwt2Character
inventory: Iwt2InventoryItem[]
collectionLog: Iwt2CollectionLog
gearProgress: Iwt2GearProgress
}
export type Iwt2BossPetAward = {
@@ -41,25 +66,42 @@ export type Iwt2BossPetAward = {
quantityAfter: number
}
export type Iwt2BossDropAward = {
bossId: Iwt2BossId
dropId: string
dropName: string
rarity: Iwt2InventoryItem['rarity']
itemLevel: number
quantity: number
duplicate: boolean
quantityAfter: number
}
export type Iwt2BossKillReward = {
save: Iwt2Save
dropAwarded: Iwt2BossDropAward
petAwarded: Iwt2BossPetAward | null
}
export type Iwt2Save = {
version: 1
version: 2
updatedAt: number
character: Iwt2Character
inventory: Iwt2InventoryItem[]
collectionLog: Iwt2CollectionLog
gearProgress: Iwt2GearProgress
cloudSlot?: Iwt2CloudSlot
}
export type Iwt2OnlineSaveResult = {
save: Iwt2Save | null
}
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
export function createDefaultIwt2Save(): Iwt2Save {
return {
version: 1,
version: 2,
updatedAt: Date.now(),
character: {
name: 'Healer',
@@ -74,15 +116,16 @@ export function createDefaultIwt2Save(): Iwt2Save {
bossPets: {},
dropsFound: {},
},
gearProgress: createDefaultIwt2GearProgress(),
}
}
function normalizeSave(value: unknown): Iwt2Save {
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
const candidate = value as Partial<Iwt2Save>
if (candidate.version !== 1) return createDefaultIwt2Save()
const candidate = value as Partial<Omit<Iwt2Save, 'version'>> & { version?: number }
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
return {
version: 1,
version: 2,
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
character: {
name: candidate.character?.name || 'Healer',
@@ -91,12 +134,13 @@ function normalizeSave(value: unknown): Iwt2Save {
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
},
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
inventory: normalizeInventory(candidate.inventory),
collectionLog: {
bossKills: candidate.collectionLog?.bossKills ?? {},
bossPets: candidate.collectionLog?.bossPets ?? {},
dropsFound: candidate.collectionLog?.dropsFound ?? {},
dropsFound: normalizeDropsFound(candidate.collectionLog?.dropsFound),
},
gearProgress: normalizeGearProgress(candidate.gearProgress),
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
}
}
@@ -109,6 +153,24 @@ export function loadIwt2Save(): Iwt2Save {
}
}
export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> {
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save')
return {
save: result.save ? normalizeSave(result.save) : null,
}
}
export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSaveResult> {
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save }),
})
return {
save: result.save ? normalizeSave(result.save) : normalizeSave(save),
}
}
export function writeIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
...save,
@@ -116,32 +178,41 @@ export function writeIwt2Save(save: Iwt2Save) {
}))
}
export function recordIwt2BossKill(save: Iwt2Save, bossId: Iwt2BossId): Iwt2Save {
return recordIwt2BossKillReward(save, bossId).save
export function recordIwt2BossKill(
save: Iwt2Save,
bossId: Iwt2BossId,
options?: { difficultySlug?: string, experienceMultiplier?: number },
): Iwt2Save {
return recordIwt2BossKillReward(save, bossId, options).save
}
export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iwt2BossKillReward {
const drop = iwt2BossMaterialRewardFor(bossId)
export function recordIwt2BossKillReward(
save: Iwt2Save,
bossId: Iwt2BossId,
options?: { difficultySlug?: string, experienceMultiplier?: number },
): Iwt2BossKillReward {
const drop = iwt2BossCoinRewardFor(bossId, options?.difficultySlug)
const pet = iwt2BossPetRewardFor(bossId)
const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE
const previousPetQuantity = save.collectionLog.bossPets[pet.id] ?? 0
const experienceReward = Math.round(125 * Math.max(0, options?.experienceMultiplier ?? 1))
const quantity = coinDropQuantity()
const inventoryResult = addInventoryItem(save.inventory, {
id: drop.id,
itemLevel: drop.itemLevel,
name: drop.name,
quantity,
rarity: drop.rarity,
})
const updatedSave: Iwt2Save = {
...save,
updatedAt: Date.now(),
character: {
...save.character,
experience: save.character.experience + 125,
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + 125) / 500)),
experience: save.character.experience + experienceReward,
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + experienceReward) / 500)),
},
inventory: [
...save.inventory,
{
id: `${drop.id}-${Date.now()}`,
name: drop.name,
quantity: 1,
rarity: 'common',
},
],
inventory: inventoryResult.inventory,
collectionLog: {
...save.collectionLog,
bossKills: {
@@ -156,12 +227,22 @@ export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iw
: save.collectionLog.bossPets,
dropsFound: {
...save.collectionLog.dropsFound,
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + quantity,
},
},
}
return {
save: updatedSave,
dropAwarded: {
bossId,
dropId: drop.id,
dropName: drop.name,
duplicate: inventoryResult.duplicate,
itemLevel: drop.itemLevel,
quantity,
quantityAfter: inventoryResult.quantityAfter,
rarity: drop.rarity,
},
petAwarded: awardedPet
? {
bossId,
@@ -175,6 +256,101 @@ export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iw
}
}
export function upgradeIwt2GearSlot(
save: Iwt2Save,
classId: Iwt2PlayerClassId,
slotId: Iwt2GearSlotId,
): Iwt2Save {
const classProgress = save.gearProgress[classId]
const slot = classProgress.slots[slotId]
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
const inventory = spendInventoryCosts(save.inventory, costs)
return {
...save,
updatedAt: Date.now(),
inventory,
gearProgress: {
...save.gearProgress,
[classId]: {
...classProgress,
slots: {
...classProgress.slots,
[slotId]: {
level: (slot.level + 1) as Iwt2GearLevel,
},
},
},
},
}
}
export function setIwt2InfusionAbility(
save: Iwt2Save,
classId: Iwt2PlayerClassId,
slotId: Iwt2GearSlotId,
abilityId: Iwt2InfusionAbilityId,
): Iwt2Save {
const classProgress = save.gearProgress[classId]
const ability = IWT2_INFUSION_ABILITIES[abilityId]
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
if (classProgress.infusionAbilityId === abilityId) return save
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
return {
...save,
updatedAt: Date.now(),
inventory,
gearProgress: {
...save.gearProgress,
[classId]: {
...classProgress,
infusionAbilityId: abilityId,
},
},
}
}
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
}
export function inventoryQuantity(inventory: Iwt2InventoryItem[], itemId: string): number {
return inventory.find((item) => item.id === itemId)?.quantity ?? 0
}
function addInventoryItem(
inventory: Iwt2InventoryItem[],
item: Iwt2InventoryItem,
): { inventory: Iwt2InventoryItem[], duplicate: boolean, quantityAfter: number } {
const nextInventory = inventory.map((candidate) => ({ ...candidate }))
const existing = nextInventory.find((candidate) => candidate.id === item.id)
if (existing) {
existing.quantity += item.quantity
return { inventory: nextInventory, duplicate: true, quantityAfter: existing.quantity }
}
nextInventory.push({ ...item })
return { inventory: nextInventory, duplicate: false, quantityAfter: item.quantity }
}
function spendInventoryCosts(
inventory: Iwt2InventoryItem[],
costs: Array<{ itemId: string, itemName: string, quantity: number }>,
): Iwt2InventoryItem[] {
for (const cost of costs) {
if (inventoryQuantity(inventory, cost.itemId) < cost.quantity) {
throw new Error(`Need ${cost.quantity} ${cost.itemName}.`)
}
}
return inventory.flatMap((item) => {
const cost = costs.find((candidate) => candidate.itemId === item.id)
if (!cost) return [{ ...item }]
const quantity = item.quantity - cost.quantity
return quantity > 0 ? [{ ...item, quantity }] : []
})
}
export function updateIwt2CharacterSettings(
save: Iwt2Save,
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
@@ -205,6 +381,7 @@ export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
bossPets: { ...save.collectionLog.bossPets },
dropsFound: { ...save.collectionLog.dropsFound },
},
gearProgress: cloneGearProgress(save.gearProgress),
},
}
}
@@ -221,6 +398,7 @@ export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
},
gearProgress: cloneGearProgress(save.cloudSlot.gearProgress),
}
}
@@ -251,11 +429,108 @@ function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined {
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
},
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
inventory: normalizeInventory(candidate.inventory),
collectionLog: {
bossKills: candidate.collectionLog.bossKills ?? {},
bossPets: candidate.collectionLog.bossPets ?? {},
dropsFound: candidate.collectionLog.dropsFound ?? {},
dropsFound: normalizeDropsFound(candidate.collectionLog.dropsFound),
},
gearProgress: normalizeGearProgress(candidate.gearProgress),
}
}
function normalizeGearProgress(value: unknown): Iwt2GearProgress {
const defaults = createDefaultIwt2GearProgress()
if (!value || typeof value !== 'object') return defaults
const candidate = value as Partial<Record<Iwt2PlayerClassId, unknown>>
const next = createDefaultIwt2GearProgress()
for (const classId of IWT2_PARTY_ORDER) {
const rawClassProgress = candidate[classId]
if (!rawClassProgress || typeof rawClassProgress !== 'object') continue
const classProgress = rawClassProgress as {
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
infusionAbilityId?: unknown
}
for (const slotId of IWT2_GEAR_SLOTS) {
next[classId].slots[slotId] = {
level: asGearLevel(classProgress.slots?.[slotId]?.level),
}
}
const infusionAbilityId = classProgress.infusionAbilityId
next[classId].infusionAbilityId = iwt2InfusionAbilitiesForClass(classId).some((ability) => ability.id === infusionAbilityId)
&& isIwt2InfusionUnlocked(next[classId])
? infusionAbilityId as Iwt2InfusionAbilityId
: null
}
return next
}
function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
return normalizeGearProgress(progress)
}
function asGearLevel(value: unknown): Iwt2GearLevel {
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
return level as Iwt2GearLevel
}
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
if (!Array.isArray(value)) return []
const byId = new Map<string, Iwt2InventoryItem>()
for (const rawItem of value) {
if (!rawItem || typeof rawItem !== 'object') continue
const item = rawItem as Partial<Iwt2InventoryItem>
if (!item.id || !item.name) continue
const bossId = legacyBossIdForDropId(item.id)
const normalizedItem = bossId
? iwt2BossCoinRewardFor(bossId, 'initiate')
: {
id: item.id,
itemLevel: Math.max(1, Math.floor(item.itemLevel ?? 1)),
name: item.name,
rarity: asItemRarity(item.rarity),
}
const quantity = Math.max(1, Math.floor(item.quantity ?? 1))
const existing = byId.get(normalizedItem.id)
if (existing) {
existing.quantity += quantity
} else {
byId.set(normalizedItem.id, {
id: normalizedItem.id,
itemLevel: normalizedItem.itemLevel,
name: normalizedItem.name,
quantity,
rarity: normalizedItem.rarity,
})
}
}
return [...byId.values()]
}
function normalizeDropsFound(value: unknown): Record<string, number> {
if (!value || typeof value !== 'object') return {}
const next: Record<string, number> = {}
for (const [rawId, rawQuantity] of Object.entries(value as Record<string, unknown>)) {
const bossId = legacyBossIdForDropId(rawId)
const id = bossId ? iwt2BossCoinRewardFor(bossId, 'initiate').id : rawId
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0))
if (quantity > 0) next[id] = (next[id] ?? 0) + quantity
}
return next
}
function legacyBossIdForDropId(dropId: string): Iwt2BossId | undefined {
return (Object.entries(IWT2_LEGACY_BOSS_MATERIAL_REWARDS) as Array<[Iwt2BossId, { id: string }]>)
.find(([, legacy]) => dropId === legacy.id || dropId.startsWith(`${legacy.id}-`))
?.[0]
}
function asItemRarity(value: unknown): Iwt2InventoryItem['rarity'] {
return value === 'uncommon'
|| value === 'rare'
|| value === 'epic'
|| value === 'legendary'
|| value === 'common'
? value
: 'common'
}
+327 -44
View File
@@ -17,6 +17,7 @@ import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKillReward,
type Iwt2BossDropAward,
type Iwt2BossPetAward,
type Iwt2Save,
} from '../save/iwt2Repository'
@@ -25,7 +26,8 @@ import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_CLASS_METADATA } from '../content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities'
import type { Iwt2Difficulty } from '../content/difficulties'
import { abilitiesForHealer, type Iwt2HealerAbility, type Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2RoguelikeContentType,
@@ -33,25 +35,45 @@ import type {
Iwt2RoguelikeSelfBuffId,
Iwt2RoguelikeVariant,
} from '../content/roguelike'
import {
createRoguelikePressureState,
roguelikeIncomingDamageScale,
} from '../sim/roguelikePressure'
import { applyIwt2PveGearStats, applyIwt2PveGearToHealerAbilities } from '../sim/equipmentStats'
import {
IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
} from '../content/roguelike'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'menu'
type OverlayAction = 'primary' | 'requeue' | 'menu'
type OverlayNavEntry = {
action: OverlayAction
row: number
}
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'menu', row: 1 },
]
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'requeue', row: 1 },
{ action: 'menu', row: 2 },
]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
type BossArenaScreenProps = {
bossId: Iwt2BossId
bossIds?: Iwt2BossId[]
difficulty?: Iwt2Difficulty
modeLabel?: string
save: Iwt2Save
onBack: () => void
onMainMenu?: () => void
onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
buffs: Iwt2RoguelikeSelfBuffId[]
@@ -63,18 +85,49 @@ type BossArenaScreenProps = {
}
}
export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp'
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
const roguelikeStage = roguelikeRun?.stage
const roguelikeContentType = roguelikeRun?.contentType
const roguelikeBuffs = roguelikeRun?.buffs ?? EMPTY_ROGUELIKE_BUFFS
const roguelikeDamageScale = roguelikeRun
? roguelikeIncomingDamageScale(roguelikeRun.stage, roguelikeRun.contentType)
: 1
const experienceMultiplier = difficulty?.experienceMultiplier ?? 1
const difficultySlug = difficulty?.slug ?? 'initiate'
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined,
))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
pvpRoguelike
? createInitialIwt2ArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
createPressureState(roguelikeStage, roguelikeContentType),
)
: null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
@@ -121,8 +174,25 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
}, [save])
const resetArena = useCallback(() => {
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
const pressureState = createPressureState(roguelikeStage, roguelikeContentType)
const next = createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
roguelikeBuffs,
pressureState,
pveGearActive ? save.gearProgress : undefined,
)
const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
pressureState,
)
: null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
@@ -131,22 +201,39 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
setArenaState(next)
setOpponentArenaState(nextOpponentState)
setAbilityCooldowns({})
setDropAwards([])
setPetAwards([])
setSelectedOverlayAction('primary')
setStatus('playing')
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
statusRef.current = nextStatus
setStatus(nextStatus)
}, [])
const resumeArena = useCallback(() => {
statusRef.current = 'playing'
setStatus('playing')
}, [])
const abilities = useMemo(
() => applyRoguelikeModifiers(
abilitiesForHealer(save.character.healerStyle),
roguelikeRun?.buffs ?? [],
roguelikeRun?.debuffs ?? [],
),
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
() => {
const baseAbilities = abilitiesForHealer(
save.character.healerStyle,
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
)
const gearAbilities = pveGearActive
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
: baseAbilities
return applyRoguelikeModifiers(
gearAbilities,
roguelikeRun?.buffs ?? [],
roguelikeRun?.debuffs ?? [],
)
},
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
@@ -168,8 +255,9 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
const moveOverlaySelection = useCallback((action: string) => {
setSelectedOverlayAction((current) => {
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
const entries = overlayNavEntriesFor(statusRef.current, pvpRoguelike)
const active = entries.find((entry) => entry.action === current) ?? entries[0]
const candidates = entries.filter((entry) => {
if (entry.action === current) return false
if (action === 'navigateUp') return entry.row < active.row
if (action === 'navigateDown') return entry.row > active.row
@@ -179,15 +267,24 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
return candidates[0]?.action ?? current
})
}, [])
}, [pvpRoguelike])
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
if (overlayAction === 'requeue') {
onPvpRequeue?.()
return
}
if (overlayAction === 'menu') {
if (statusRef.current === 'paused') {
const returnToMainMenu = onMainMenu ?? onBack
returnToMainMenu()
return
}
onBack()
return
}
if (statusRef.current === 'paused') {
setStatus('playing')
resumeArena()
return
}
if (statusRef.current === 'victory' && roguelikeRun) {
@@ -195,10 +292,10 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
return
}
resetArena()
}, [onBack, resetArena, roguelikeRun])
}, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun])
useGameAction((action, device) => {
if (device === 'controller' && statusRef.current !== 'playing') {
if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) {
if (action.startsWith('navigate')) {
moveOverlaySelection(action)
return
@@ -208,10 +305,15 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
return
}
if (action === 'back') {
if (statusRef.current === 'paused') setStatus('playing')
if (statusRef.current === 'paused') resumeArena()
return
}
}
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') resumeArena()
return
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
@@ -240,11 +342,6 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
if (target) setSelectedPartyId(target.id)
return
}
if (device !== 'controller') return
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') setStatus('playing')
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
@@ -273,21 +370,33 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
if (newlyDefeatedBosses.length > 0) {
const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
const newDropAwards: Iwt2BossDropAward[] = []
const newPetAwards: Iwt2BossPetAward[] = []
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
difficultySlug,
experienceMultiplier,
})
updatedSave = reward.save
newDropAwards.push(reward.dropAwarded)
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave
if (newDropAwards.length > 0) setDropAwards((current) => [...current, ...newDropAwards])
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
showOverlay('victory')
if (pvpRoguelike && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else {
showOverlay('victory')
}
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
}
@@ -305,7 +414,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [onSaveUpdated, pvpRoguelike, showOverlay])
}, [difficultySlug, experienceMultiplier, onSaveUpdated, pvpRoguelike, roguelikeRun, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
@@ -314,11 +423,14 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
const victoryExperience = Math.round(arenaState.bosses.length * 125 * experienceMultiplier)
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
const overlayPrimaryLabel = status === 'paused'
? 'Resume'
: status === 'victory' && roguelikeRun
? 'Choose Upgrade'
: pvpRoguelike && status !== 'playing'
? 'Rematch'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossTitle} Down`
@@ -338,12 +450,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
const pauseCopy = roguelikeRun?.variant === 'pvp'
? undefined
: 'Combat is stopped. Resume the fight or leave the current run.'
const pauseLeaveLabel = roguelikeRun?.variant === 'pvp'
? 'Leave'
: roguelikeRun
? 'Leave Roguelike'
: `Leave ${modeLabel ?? 'Arena'}`
: 'Combat is stopped. Continue the fight or return to the main menu.'
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
@@ -408,23 +515,21 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
{pauseCopy && <p>{pauseCopy}</p>}
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Resume
Continue
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
{pauseLeaveLabel}
Main Menu
</button>
</div>
</div>
@@ -463,9 +568,13 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+{arenaState.bosses.length * 125} XP</span>
<span>{arenaState.bosses.length} carves</span>
<span>+{victoryExperience} XP</span>
<span>Log +{arenaState.bosses.length}</span>
{dropAwards.map((award) => (
<span key={`${award.dropId}:${award.quantityAfter}`}>
{award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''}
</span>
))}
{petAwards.map((award) => (
<span key={`${award.petId}:${award.quantityAfter}`}>
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
@@ -478,14 +587,27 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
{overlayPrimaryLabel}
</button>
{pvpRoguelike && (
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'requeue' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'requeue' ? 'true' : undefined}
onClick={() => activateOverlayAction('requeue')}
onPointerDown={() => setSelectedOverlayAction('requeue')}
type="button"
>
Requeue
</button>
)}
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
Menu
@@ -518,7 +640,12 @@ function formatArenaTime(seconds: number): string {
}
function roguelikeBossHealthScale(stage: number): number {
return 0.5 + Math.max(0, stage - 1) * 0.1
return 1 + Math.max(0, stage - 1) * 0.1
}
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
return DEFAULT_OVERLAY_NAV_ENTRIES
}
function arenaHudSignature(state: Iwt2ArenaState): string {
@@ -566,12 +693,66 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
return changed ? next : cooldowns
}
function createArenaState(
bossId: Iwt2BossId,
bossIds: Iwt2BossId[] | undefined,
bossHealthScale: number,
partyDamageTakenScale: number,
buffs: Iwt2RoguelikeSelfBuffId[],
roguelikePressure: ReturnType<typeof createPressureState>,
gearProgress?: Iwt2Save['gearProgress'],
): Iwt2ArenaState {
const baseState = createInitialIwt2ArenaState(
bossId,
bossIds,
bossHealthScale,
partyDamageTakenScale,
roguelikePressure,
)
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
const shieldedHotHealingMultiplier = buffs.includes(IWT2_BARKSKIN_HOT_BONUS_BUFF_ID) ? 1.25 : undefined
if (shieldedDamageTakenMultiplier === undefined && shieldedHotHealingMultiplier === undefined) return state
return {
...state,
party: state.party.map((member) => ({
...member,
shieldedDamageTakenMultiplier,
shieldedHotHealingMultiplier,
})),
}
}
function createPressureState(
stage: number | undefined,
contentType: Iwt2RoguelikeContentType | undefined,
) {
return stage && contentType ? createRoguelikePressureState(stage, contentType) : undefined
}
function shieldedDamageTakenMultiplierForBuffs(buffs: Iwt2RoguelikeSelfBuffId[]) {
const multipliers: number[] = []
if (buffs.includes(IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.5)
if (buffs.includes(IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.7)
return multipliers.length > 0 ? Math.min(...multipliers) : undefined
}
function applyRoguelikeModifiers(
abilities: Iwt2HealerAbility[],
buffs: Iwt2RoguelikeSelfBuffId[],
debuffs: Iwt2RoguelikeOpponentDebuffId[],
): Iwt2HealerAbility[] {
if (buffs.length === 0 && debuffs.length === 0) return abilities
const abilityById = new Map(abilities.map((ability) => [ability.id, ability]))
const renewEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-renew'))
const sunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'))
const halfSunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'), 0.5)
const seedOfLifeEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-seed-of-life'))
const barkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'))
const halfBarkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'), 0.5)
const mendingRuneEffect = triggeredEffectFromAbility(abilityById.get('runesage-mending-rune'))
const aegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'))
const halfAegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'), 0.5)
return abilities.map((ability) => {
const slot = String(ability.slot)
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
@@ -579,16 +760,118 @@ function applyRoguelikeModifiers(
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
const triggeredEffects = iwt2TriggeredEffectsForAbility({
ability,
aegisScriptEffect,
barkskinEffect,
buffs,
halfAegisScriptEffect,
halfBarkskinEffect,
halfSunWardEffect,
mendingRuneEffect,
renewEffect,
seedOfLifeEffect,
sunWardEffect,
})
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0 && triggeredEffects.length === 0) return ability
return {
...ability,
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
triggeredEffects: triggeredEffects.length > 0 ? triggeredEffects : ability.triggeredEffects,
}
})
}
function iwt2TriggeredEffectsForAbility({
ability,
aegisScriptEffect,
barkskinEffect,
buffs,
halfAegisScriptEffect,
halfBarkskinEffect,
halfSunWardEffect,
mendingRuneEffect,
renewEffect,
seedOfLifeEffect,
sunWardEffect,
}: {
ability: Iwt2HealerAbility
aegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
barkskinEffect: Iwt2TriggeredAbilityEffect | undefined
buffs: Iwt2RoguelikeSelfBuffId[]
halfAegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
halfBarkskinEffect: Iwt2TriggeredAbilityEffect | undefined
halfSunWardEffect: Iwt2TriggeredAbilityEffect | undefined
mendingRuneEffect: Iwt2TriggeredAbilityEffect | undefined
renewEffect: Iwt2TriggeredAbilityEffect | undefined
seedOfLifeEffect: Iwt2TriggeredAbilityEffect | undefined
sunWardEffect: Iwt2TriggeredAbilityEffect | undefined
}): Iwt2TriggeredAbilityEffect[] {
const effects = [...ability.triggeredEffects ?? []]
const addEffect = (effect: Iwt2TriggeredAbilityEffect | undefined) => {
if (effect && !effects.some((existing) => existing.id === effect.id && existing.power === effect.power)) {
effects.push(effect)
}
}
if (ability.id === 'dawnweaver-mend') {
if (buffs.includes('dawnweaver-mend-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-mend-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'dawnweaver-renew') {
if (buffs.includes('dawnweaver-renew-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'dawnweaver-radiance') {
if (buffs.includes('dawnweaver-radiance-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-radiance-applies-sun-ward')) addEffect(halfSunWardEffect)
} else if (ability.id === 'dawnweaver-sun-ward') {
if (buffs.includes('dawnweaver-sun-ward-applies-renew')) addEffect(renewEffect)
} else if (ability.id === 'dawnweaver-purify') {
if (buffs.includes('dawnweaver-purify-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-purify-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'lifebinder-verdant-touch') {
if (buffs.includes('lifebinder-verdant-touch-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-verdant-touch-applies-barkskin')) addEffect(halfBarkskinEffect)
} else if (ability.id === 'lifebinder-seed-of-life') {
if (buffs.includes('lifebinder-seed-of-life-applies-barkskin')) addEffect(barkskinEffect)
} else if (ability.id === 'lifebinder-wild-growth') {
if (buffs.includes('lifebinder-wild-growth-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-wild-growth-applies-barkskin')) addEffect(halfBarkskinEffect)
} else if (ability.id === 'lifebinder-barkskin') {
if (buffs.includes('lifebinder-barkskin-applies-seed-of-life')) addEffect(seedOfLifeEffect)
} else if (ability.id === 'lifebinder-purging-sap') {
if (buffs.includes('lifebinder-purging-sap-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-purging-sap-applies-barkskin')) addEffect(barkskinEffect)
} else if (ability.id === 'runesage-etched-mend') {
if (buffs.includes('runesage-etched-mend-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-etched-mend-applies-aegis-script')) addEffect(halfAegisScriptEffect)
} else if (ability.id === 'runesage-mending-rune') {
if (buffs.includes('runesage-mending-rune-applies-aegis-script')) addEffect(aegisScriptEffect)
} else if (ability.id === 'runesage-concordance') {
if (buffs.includes('runesage-concordance-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-concordance-applies-aegis-script')) addEffect(halfAegisScriptEffect)
} else if (ability.id === 'runesage-aegis-script') {
if (buffs.includes('runesage-aegis-script-applies-mending-rune')) addEffect(mendingRuneEffect)
} else if (ability.id === 'runesage-unravel') {
if (buffs.includes('runesage-unravel-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-unravel-applies-aegis-script')) addEffect(aegisScriptEffect)
}
return effects
}
function triggeredEffectFromAbility(
ability: Iwt2HealerAbility | undefined,
strength = 1,
): Iwt2TriggeredAbilityEffect | undefined {
if (!ability) return undefined
return {
effectType: ability.effectType,
id: ability.id,
kind: ability.kind,
name: ability.name,
power: Math.max(1, Math.round(ability.power * strength)),
}
}
function countStacks(items: readonly string[], id: string) {
return items.filter((item) => item === id).length
}
File diff suppressed because it is too large Load Diff
+55 -16
View File
@@ -13,6 +13,7 @@ import type {
Iwt2PartyEntityId,
Iwt2PartyEntityState,
Iwt2ProjectileEntityState,
Iwt2RoguelikePressureState,
Iwt2StatusState,
Iwt2Vec2,
} from './types'
@@ -22,6 +23,7 @@ import { separateCircles } from './collision'
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
import { applyPartyDamageInShape } from './mechanics'
import { tickRoguelikePressure } from './roguelikePressure'
import {
addVec2,
clampVec2ToArena,
@@ -62,6 +64,8 @@ export function createInitialIwt2ArenaState(
bossId: Iwt2BossId = 'bulldrome',
bossIds?: Iwt2BossId[],
bossHealthScale = 1,
partyDamageTakenScale = 1,
roguelikePressure?: Iwt2RoguelikePressureState,
): Iwt2ArenaState {
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
@@ -69,11 +73,12 @@ export function createInitialIwt2ArenaState(
schemaVersion: 1,
time: 0,
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
party: INITIAL_PARTY.map(createPartyMember),
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
projectiles: [],
hostileAdds: [],
hazards: [],
indicators: [],
roguelikePressure,
boss: bosses[0],
bosses,
nextEventId: 1,
@@ -216,8 +221,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
party: projectileResult.party,
time: separatedState.time,
})
const pressureResult = tickRoguelikePressure({
party: hazardResult.party,
pressure: separatedState.roguelikePressure,
time: separatedState.time,
})
const damageResult = applyPartyAttacks(
hazardResult.party,
pressureResult.party,
projectileResult.bosses,
projectileResult.hostileAdds,
separatedState.time,
@@ -234,7 +244,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
finalCollisionState,
)
const nextEvents = assignEventIds(
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events],
[...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...pressureResult.events, ...damageResult.events, ...hotResult.events],
state.nextEventId,
)
return {
@@ -242,6 +252,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
boss: getPrimaryBoss(damageResult.bosses),
bosses: damageResult.bosses,
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
roguelikePressure: pressureResult.pressure,
party: finalParty,
hostileAdds: damageResult.hostileAdds,
hazards: hazardResult.hazards,
@@ -270,7 +281,10 @@ function tickPartyHotEffects(
let nextTickInSeconds = effect.nextTickInSeconds - dt
if (member.health > 0 && nextTickInSeconds <= 0) {
const before = health
health = Math.min(member.maxHealth, health + effect.power)
const healingMultiplier = member.shield > 0
? member.shieldedHotHealingMultiplier ?? 1
: 1
health = Math.min(member.maxHealth, health + effect.power * healingMultiplier)
const healed = health - before
if (healed > 0) {
events.push({
@@ -310,8 +324,9 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
})
}
function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[initial.classId]
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
return {
id: initial.id,
kind: 'party',
@@ -321,8 +336,16 @@ function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
velocity: { x: 0, y: 0 },
facing: { x: 1, y: 0 },
radius: metadata.radius,
moveSpeed: metadata.moveSpeed,
attackRange: metadata.attackRange,
attackDamage: metadata.attackDamage,
attackCooldown: metadata.attackCooldown,
castTime: metadata.castTime,
projectileSpeed: metadata.projectileSpeed,
health: metadata.maxHealth,
maxHealth: metadata.maxHealth,
damageTakenScale: safeDamageTakenScale,
stunTakenScale: 1,
shield: 0,
mana: initial.classId === 'healer' ? 100 : 0,
maxMana: initial.classId === 'healer' ? 100 : 0,
@@ -447,13 +470,15 @@ function separatePartyFromBosses(party: Iwt2PartyEntityState[], state: Iwt2Arena
let position = member.position
for (const boss of state.bosses) {
if (boss.health <= 0) continue
const overlapBeforeSeparation = circleOverlapAmount({ position, radius: member.radius }, boss)
const separated = separateCircles(
{ position, radius: member.radius },
{ position: boss.position, radius: boss.radius },
state.bounds,
)
position = wallClearance(separated, member, state) <= 2
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
const overlapAfterSeparation = circleOverlapAmount({ position: separated, radius: member.radius }, boss)
position = overlapBeforeSeparation > 0.25
&& (wallClearance(separated, member, state) <= 2 || overlapAfterSeparation > 0.25)
? separateFromBossTowardCenter(member, boss, state)
: separated
}
@@ -802,7 +827,7 @@ function applyPartyAttacks(
if (!target) return member
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.projectileSpeed > 0) {
if (member.projectileSpeed > 0) {
if (!member.attackReady) return member
const direction = normalizeVec2(subtractVec2(target.position, member.position))
projectiles.push({
@@ -810,29 +835,36 @@ function applyPartyAttacks(
sourceId: member.id,
owner: 'party',
classId: member.classId,
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
projectileKind: member.classId === 'mage' ? 'fireball' : 'arrow',
color: metadata.accentColor,
position: {
x: member.position.x + direction.x * (member.radius + 4),
y: member.position.y + direction.y * (member.radius + 4),
},
velocity: scaleVec2(direction, metadata.projectileSpeed),
velocity: scaleVec2(direction, member.projectileSpeed),
radius: member.classId === 'mage' ? 7 : 4,
damage: metadata.attackDamage,
damage: member.attackDamage,
remainingSeconds: 1.2,
})
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
projectileId += 1
return {
...member,
attackCooldownRemaining: metadata.attackCooldown,
attackCooldownRemaining: member.attackCooldown,
attackReady: false,
}
}
if (member.attackCooldownRemaining > 0) return member
let damage = Math.min(metadata.attackDamage, target.health)
let damage = Math.min(member.attackDamage, target.health)
let defeated = target.health - damage <= 0
if (target.kind === 'boss') {
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
const damageResult = applyDamageToBoss(target, member.attackDamage)
damage = damageResult.healthDamage
defeated = damageResult.boss.health <= 0
nextBosses = nextBosses.map((boss) => boss.id === target.id
@@ -851,6 +883,13 @@ function applyPartyAttacks(
targetId: target.id,
value: damage,
})
events.push({
id: 0,
time,
type: 'partyAttack',
sourceId: member.id,
targetId: target.id,
})
if (defeated) {
events.push({
id: 0,
@@ -862,13 +901,13 @@ function applyPartyAttacks(
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
attackCooldownRemaining: member.attackCooldown,
}
}
return {
...member,
damageDone: member.damageDone + damage,
attackCooldownRemaining: metadata.attackCooldown,
attackCooldownRemaining: member.attackCooldown,
}
})
return {
+4 -1
View File
@@ -11,6 +11,7 @@ import type {
Iwt2BossEntityState,
Iwt2HostileAddState,
Iwt2PartyEntityState,
Iwt2RoguelikePressureState,
} from './types'
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
@@ -58,8 +59,10 @@ export function createInitialIwt2ArenaState(
bossId?: Iwt2BossId,
bossIds?: Iwt2BossId[],
bossHealthScale?: number,
partyDamageTakenScale?: number,
roguelikePressure?: Iwt2RoguelikePressureState,
): Iwt2ArenaState {
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale))
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
}
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
+9 -3
View File
@@ -27,6 +27,7 @@ import { tickTobiKadachi } from './tobiKadachiAi'
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
import { tickYianKutKu } from './yianKutKuAi'
import {
applyPartyDamageToMember,
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
@@ -281,14 +282,19 @@ function maybeApplyMelee(
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
let appliedDamage = 0
let nextTargetHealth = target.health
const nextParty = party.map((member) => {
if (member.id !== target.id) return member
return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) }
const result = applyPartyDamageToMember(member, BULLDROME_BOSS_METADATA.meleeDamage)
appliedDamage = result.damage
nextTargetHealth = result.member.health
return result.member
})
const events = [
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage),
createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, appliedDamage),
]
if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) {
if (target.health > 0 && nextTargetHealth <= 0) {
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
}
return {
+4 -4
View File
@@ -37,22 +37,22 @@ const CINDERBACK_PHASE = {
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
const CINDERBACK_TUNING = {
armorSlamDamage: 20,
armorSlamDamage: 44,
armorSlamRadius: 118,
armorSlamRecover: 1.05,
armorSlamStunSeconds: 0.7,
armorSlamWindup: 0.62,
centerDisengageSpeed: 152,
firePuddleDamage: 5,
firePuddleDamage: 15,
firePuddleRadius: 34,
firePuddleSeconds: 6.4,
lavaTrailInterval: 0.26,
meleeCooldown: 1.12,
meleeDamage: 5,
meleeDamage: 11,
meleeRange: 58,
moveSpeed: 104,
ricochetCooldown: 7.2,
ricochetDamage: 18,
ricochetDamage: 40,
ricochetDuration: 3.1,
ricochetMaxBounces: 4,
ricochetSpeed: 470,
+4 -4
View File
@@ -44,26 +44,26 @@ const CRYSTAL_BAT_TUNING = {
clusterRadius: 84,
hoverDistance: 138,
meleeCooldown: 1.18,
meleeDamage: 4,
meleeDamage: 10,
meleeRange: 54,
moveSpeed: 138,
relocateCooldownFloor: 1.2,
relocateSeconds: 1.25,
relocateSpeed: 184,
shardCooldown: 5.8,
shardDamage: 13,
shardDamage: 34,
shardLaneWidth: 30,
shardRecover: 0.58,
shardStunSeconds: 0.35,
shardWindup: 0.82,
sonicCooldown: 6.4,
sonicDamage: 10,
sonicDamage: 30,
sonicRecover: 0.42,
sonicRingRadius: 58,
sonicStunSeconds: 0.32,
sonicWindup: 0.78,
swoopCooldown: 4.9,
swoopDamage: 17,
swoopDamage: 42,
swoopLaneWidth: 44,
swoopRecover: 0.62,
swoopSpeed: 430,
+3 -3
View File
@@ -36,16 +36,16 @@ type EmberMantisAttackPhase =
const EMBER_MANTIS = {
accentColor: '#ffb13b',
crossSlashCooldown: 7.5,
crossSlashDamage: 20,
crossSlashDamage: 44,
crossSlashStunSeconds: 0.45,
crossSlashWindup: 0.82,
duelistRange: 118,
lineSlashCooldown: 3.4,
lineSlashDamage: 15,
lineSlashDamage: 36,
lineSlashStunSeconds: 0.25,
lineSlashWindup: 0.48,
meleeCooldown: 0.8,
meleeDamage: 7,
meleeDamage: 12,
meleeRange: 52,
moveSpeed: 168,
recoverSeconds: 0.38,
+86
View File
@@ -0,0 +1,86 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import {
IWT2_GEAR_SLOT_RECIPES,
IWT2_GEAR_SLOTS,
type Iwt2GearProgress,
} from '../content/gear'
import type { Iwt2ArenaState, Iwt2PartyEntityState } from './types'
import { applyIwt2InfusionLoadout } from './infusionLoadout'
const PER_LEVEL_OUTPUT = 0.04
const PER_LEVEL_HEALTH = 0.04
const PER_LEVEL_SPEED = 0.025
const PER_LEVEL_COOLDOWN = 0.03
const PER_LEVEL_PROJECTILE = 0.03
const PER_LEVEL_DAMAGE_TAKEN_REDUCTION = 0.03
const PER_LEVEL_STUN_REDUCTION = 0.08
export function applyIwt2PveGearStats<TState extends Pick<Iwt2ArenaState, 'party'>>(state: TState, gearProgress: Iwt2GearProgress): TState {
const party = state.party.map((member) => applyMemberGearStats(member, gearProgress))
return applyIwt2InfusionLoadout({
...state,
party,
}, gearProgress)
}
export function applyIwt2PveGearToHealerAbilities(
abilities: Iwt2HealerAbility[],
gearProgress: Iwt2GearProgress,
): Iwt2HealerAbility[] {
const healer = gearProgress.healer
const healingLevel = healer.slots.weapon.level
const cooldownLevel = healer.slots.helmet.level
if (healingLevel <= 0 && cooldownLevel <= 0) return abilities
return abilities.map((ability) => ({
...ability,
cooldownSeconds: roundOne(ability.cooldownSeconds * (1 - cooldownLevel * PER_LEVEL_COOLDOWN)),
power: Math.max(1, Math.round(ability.power * (1 + healingLevel * PER_LEVEL_OUTPUT))),
}))
}
function applyMemberGearStats(
member: Iwt2PartyEntityState,
gearProgress: Iwt2GearProgress,
): Iwt2PartyEntityState {
const classProgress = gearProgress[member.classId]
let next: Iwt2PartyEntityState = {
...member,
gearLevels: {
weapon: classProgress.slots.weapon.level,
helmet: classProgress.slots.helmet.level,
chest: classProgress.slots.chest.level,
legs: classProgress.slots.legs.level,
feet: classProgress.slots.feet.level,
},
}
for (const slotId of IWT2_GEAR_SLOTS) {
const level = classProgress.slots[slotId].level
if (level <= 0) continue
const statId = IWT2_GEAR_SLOT_RECIPES[member.classId][slotId].statId
if (statId === 'maxHealth') {
const maxHealth = Math.round(next.maxHealth * (1 + level * PER_LEVEL_HEALTH))
next = {
...next,
health: Math.min(maxHealth, Math.round(next.health * (maxHealth / next.maxHealth))),
maxHealth,
}
} else if (statId === 'moveSpeed') {
next = { ...next, moveSpeed: next.moveSpeed * (1 + level * PER_LEVEL_SPEED) }
} else if (statId === 'damage') {
next = { ...next, attackDamage: next.attackDamage * (1 + level * PER_LEVEL_OUTPUT) }
} else if (statId === 'attackCooldown') {
next = { ...next, attackCooldown: next.attackCooldown * (1 - level * PER_LEVEL_COOLDOWN) }
} else if (statId === 'projectileSpeed') {
next = { ...next, projectileSpeed: next.projectileSpeed * (1 + level * PER_LEVEL_PROJECTILE) }
} else if (statId === 'hazardDamageTaken') {
next = { ...next, damageTakenScale: next.damageTakenScale * (1 - level * PER_LEVEL_DAMAGE_TAKEN_REDUCTION) }
} else if (statId === 'stunResist') {
next = { ...next, stunTakenScale: Math.max(0, next.stunTakenScale * (1 - level * PER_LEVEL_STUN_REDUCTION)) }
}
}
return next
}
function roundOne(value: number): number {
return Math.max(0.1, Math.round(value * 10) / 10)
}
+40 -1
View File
@@ -1,4 +1,4 @@
import type { Iwt2HealerAbility } from '../content/healerAbilities'
import type { Iwt2HealerAbility, Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
import type {
Iwt2ArenaEvent,
Iwt2ArenaState,
@@ -31,6 +31,8 @@ export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
const events: Iwt2ArenaEvent[] = []
let eventId = state.nextEventId
events.push(createArenaEvent(eventId, state.time, 'healerSpellCast', HEALER_ID, targetIds[0]))
eventId += 1
const targetIdSet = new Set(targetIds)
const nextParty = state.party.map((member) => {
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
@@ -82,6 +84,12 @@ function targetIdsForAbility(
}
function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
const baseMember = applyAbilityCoreToMember(member, ability)
if (!ability.triggeredEffects || ability.triggeredEffects.length === 0) return baseMember
return ability.triggeredEffects.reduce(applyTriggeredEffectToMember, baseMember)
}
function applyAbilityCoreToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState {
if (ability.kind === 'hot') {
return {
...healMember(member, ability.power),
@@ -120,6 +128,37 @@ function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerA
return healMember(member, ability.power)
}
function applyTriggeredEffectToMember(
member: Iwt2PartyEntityState,
effect: Iwt2TriggeredAbilityEffect,
): Iwt2PartyEntityState {
if (effect.kind === 'hot') {
return {
...healMember(member, effect.power),
hotEffects: [
...member.hotEffects.filter((hotEffect) => hotEffect.id !== effect.id),
{
id: effect.id,
label: effect.name,
power: Math.max(1, Math.round(effect.power * 0.5)),
remainingSeconds: DEFAULT_HOT_SECONDS,
tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS,
nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS,
},
],
}
}
if (effect.kind === 'shield') {
return {
...member,
shield: Math.max(member.shield, effect.power),
}
}
return healMember(member, effect.power)
}
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
return {
...member,
+5 -5
View File
@@ -44,7 +44,7 @@ type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSp
const HOLLOWCROWN_TUNING = {
antlerCooldown: 7.4,
antlerDamage: 17,
antlerDamage: 42,
antlerLaneLength: 440,
antlerLaneOffset: 44,
antlerLineWidth: 30,
@@ -52,22 +52,22 @@ const HOLLOWCROWN_TUNING = {
antlerStunSeconds: 0.48,
antlerWindup: 0.82,
dashCooldown: 5.2,
dashDamage: 13,
dashDamage: 34,
dashLength: 330,
dashRecover: 0.42,
dashSpeed: 430,
dashStunSeconds: 0.32,
dashTrailDamage: 9,
dashTrailDamage: 20,
dashTrailSeconds: 0.5,
dashTrailWidth: 40,
hoofCooldown: 6.2,
hoofDamage: 12,
hoofDamage: 34,
hoofRadius: 46,
hoofRecover: 0.46,
hoofStunSeconds: 0.34,
hoofWindup: 0.74,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeDamage: 11,
meleeRange: 58,
moveSpeed: 116,
relocateSeconds: 1.25,
+5
View File
@@ -27,6 +27,11 @@ export {
castIwt2HealerAbility,
} from './healing'
export {
applyIwt2InfusionLoadout,
selectedInfusionForMember,
} from './infusionLoadout'
export {
addVec2,
clamp,
+43
View File
@@ -0,0 +1,43 @@
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbility } from '../content/infusionAbilities'
import type { Iwt2GearProgress } from '../content/gear'
import type { Iwt2ArenaState, Iwt2PartyEntityState } from './types'
export function applyIwt2InfusionLoadout<TState extends Pick<Iwt2ArenaState, 'party'>>(
state: TState,
gearProgress: Iwt2GearProgress,
): TState {
return {
...state,
party: state.party.map((member) => {
const ability = selectedInfusionForMember(member, gearProgress)
return ability ? applyInfusionToMember(member, ability) : member
}),
}
}
export function selectedInfusionForMember(
member: Pick<Iwt2PartyEntityState, 'classId'>,
gearProgress: Iwt2GearProgress,
): Iwt2InfusionAbility | null {
const abilityId = gearProgress[member.classId].infusionAbilityId
if (!abilityId) return null
const ability = IWT2_INFUSION_ABILITIES[abilityId]
return ability?.classId === member.classId ? ability : null
}
function applyInfusionToMember(
member: Iwt2PartyEntityState,
ability: Iwt2InfusionAbility,
): Iwt2PartyEntityState {
const baseMember = {
...member,
infusionAbilityId: ability.id,
}
if (ability.effectKey === 'unbreakable') {
return {
...baseMember,
stunTakenScale: 0,
}
}
return baseMember
}
+29 -11
View File
@@ -127,25 +127,22 @@ export function applyPartyDamageInShape(
const events: Iwt2ArenaEvent[] = []
const excluded = new Set(effect.excludedEntityIds ?? [])
const hitEntityIds: Iwt2EntityId[] = []
const stunSeconds = effect.stunSeconds ?? 0
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
const nextParty = party.map((member) => {
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
return member
}
const stunSeconds = (effect.stunSeconds ?? 0) * member.stunTakenScale
const knockdownSeconds = (effect.knockdownSeconds ?? (effect.stunSeconds ?? 0)) * member.stunTakenScale
const damageResult = applyPartyDamageToMember(member, effect.damage)
hitEntityIds.push(member.id)
const shieldAbsorbed = Math.min(member.shield, effect.damage)
const healthDamage = effect.damage - shieldAbsorbed
const nextShield = member.shield - shieldAbsorbed
const nextHealth = Math.max(0, member.health - healthDamage)
events.push(createArenaEvent(
0,
effect.time,
effect.damageEventType ?? 'partyDamaged',
effect.sourceId,
member.id,
effect.damage,
damageResult.damage,
))
if (stunSeconds > 0 || knockdownSeconds > 0) {
events.push(createArenaEvent(
@@ -157,14 +154,12 @@ export function applyPartyDamageInShape(
Math.max(stunSeconds, knockdownSeconds),
))
}
if (member.health > 0 && nextHealth <= 0) {
if (member.health > 0 && damageResult.member.health <= 0) {
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
}
return {
...member,
health: nextHealth,
shield: nextShield,
...damageResult.member,
status: {
...member.status,
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
@@ -176,6 +171,29 @@ export function applyPartyDamageInShape(
return { party: nextParty, hitEntityIds, events }
}
export function applyPartyDamageToMember(
member: Iwt2PartyEntityState,
damage: number,
): {
member: Iwt2PartyEntityState
damage: number
} {
const shieldedDamageMultiplier = member.shield > 0
? member.shieldedDamageTakenMultiplier ?? 1
: 1
const scaledDamage = Math.max(0, damage * member.damageTakenScale * shieldedDamageMultiplier)
const shieldAbsorbed = Math.min(member.shield, scaledDamage)
const healthDamage = scaledDamage - shieldAbsorbed
return {
damage: scaledDamage,
member: {
...member,
health: Math.max(0, member.health - healthDamage),
shield: member.shield - shieldAbsorbed,
},
}
}
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
if (shape.kind === 'lane') {
return circleIntersectsSegment(
+4 -4
View File
@@ -47,7 +47,7 @@ const OBSIDIAN_RAM = {
armorCracksBeforeShatter: 3,
centerDisengageSpeed: 118,
chargeCooldown: 4.4,
chargeDamage: 8,
chargeDamage: 24,
chargeLength: 340,
chargeRecover: 0.5,
chargeSpeed: 390,
@@ -55,11 +55,11 @@ const OBSIDIAN_RAM = {
chargeWidth: 54,
chargeWindup: 0.68,
fractureLaneCount: 5,
fractureLaneDamage: 6,
fractureLaneDamage: 20,
fractureLaneLength: 300,
fractureLaneWidth: 30,
meleeCooldown: 1.3,
meleeDamage: 2,
meleeDamage: 8,
meleeRange: 62,
moveSpeed: 82,
quakeCooldown: 7.4,
@@ -67,7 +67,7 @@ const OBSIDIAN_RAM = {
quakeRecover: 0.48,
quakeStunSeconds: 0.35,
quakeWindup: 0.78,
slabDamage: 10,
slabDamage: 22,
slabDuration: 5.8,
slabRadius: 42,
slabSlowRadius: 46,
+5 -8
View File
@@ -1,4 +1,3 @@
import { IWT2_CLASS_METADATA } from '../content/classes'
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
@@ -34,7 +33,6 @@ export function tickPartyMember(
inputMove: Iwt2Vec2,
dt: number,
): Iwt2PartyEntityState {
const metadata = IWT2_CLASS_METADATA[member.classId]
const status = {
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
@@ -52,7 +50,7 @@ export function tickPartyMember(
if (member.aiRole === 'player') {
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
const velocity = scaleVec2(normalizedInput, metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const velocity = scaleVec2(normalizedInput, member.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
const position = clampVec2ToArena(
{
x: member.position.x + velocity.x * dt,
@@ -82,7 +80,7 @@ export function tickPartyMember(
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
? castSecondsRemaining
: metadata.castTime
: member.castTime
return {
...member,
velocity: { x: 0, y: 0 },
@@ -96,7 +94,7 @@ export function tickPartyMember(
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
const maxDistance = metadata.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const maxDistance = member.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
const distanceToDesired = distanceVec2(member.position, desired)
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
const position = movingToDesired
@@ -229,9 +227,8 @@ function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
if (member.health <= 0) return false
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
const metadata = IWT2_CLASS_METADATA[member.classId]
if (metadata.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
if (member.attackDamage <= 0) return false
return distanceVec2(member.position, targetPosition) <= member.attackRange + member.radius + targetRadius
}
function getPartyDesiredPosition(
+4 -4
View File
@@ -49,7 +49,7 @@ type ShardCone = {
const RIMEBASTION_BALANCE = {
iceWallCooldown: 7.2,
iceWallDamage: 6,
iceWallDamage: 18,
iceWallStunSeconds: 0.25,
iceWallWindup: 1,
iceCircleCount: 2,
@@ -57,16 +57,16 @@ const RIMEBASTION_BALANCE = {
iceWallLaneCount: 3,
iceWallWidth: 22,
meleeCooldown: 1.45,
meleeDamage: 4,
meleeDamage: 10,
meleeRange: 66,
moveSpeed: 64,
relocateCooldownFloor: 1.3,
relocateSpeed: 112,
relocateSeconds: 1.6,
shatterConeAngleRadians: Math.PI * 0.34,
shatterConeDamage: 9,
shatterConeDamage: 30,
shatterConeRange: 158,
shatterLaneDamage: 11,
shatterLaneDamage: 34,
shatterLaneWidth: 34,
shatterRecover: 0.52,
shatterStunSeconds: 0.4,
+87
View File
@@ -0,0 +1,87 @@
import type {
Iwt2ArenaEvent,
Iwt2PartyEntityState,
Iwt2RoguelikePressureContentType,
Iwt2RoguelikePressureState,
} from './types'
import { applyPartyDamageToMember, createArenaEvent } from './mechanics'
const ROGUELIKE_PRESSURE_INTERVAL_SECONDS = 5
const ROGUELIKE_PRESSURE_BASE_DAMAGE = 9
const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
export function roguelikeIncomingDamageScale(
_stage: number,
_contentType: Iwt2RoguelikePressureContentType,
): number {
return 1
}
export function createRoguelikePressureState(
stage: number,
contentType: Iwt2RoguelikePressureContentType,
): Iwt2RoguelikePressureState {
const safeStage = safeRoguelikeStage(stage)
return {
stage: safeStage,
contentType,
damage: Math.round(ROGUELIKE_PRESSURE_BASE_DAMAGE + ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE),
intervalSeconds: ROGUELIKE_PRESSURE_INTERVAL_SECONDS,
nextPulseAt: ROGUELIKE_PRESSURE_INTERVAL_SECONDS,
}
}
export function tickRoguelikePressure({
party,
pressure,
time,
}: {
party: Iwt2PartyEntityState[]
pressure?: Iwt2RoguelikePressureState
time: number
}): {
events: Iwt2ArenaEvent[]
party: Iwt2PartyEntityState[]
pressure?: Iwt2RoguelikePressureState
} {
if (!pressure || time < pressure.nextPulseAt) {
return { events: [], party, pressure }
}
const events: Iwt2ArenaEvent[] = []
const nextParty = party.map((member) => {
if (member.health <= 0) return member
const before = member.health
const result = applyPartyDamageToMember(member, pressure.damage)
events.push(createArenaEvent(
0,
time,
'partyDamaged',
ROGUELIKE_PRESSURE_SOURCE_ID,
member.id,
result.damage,
))
if (before > 0 && result.member.health <= 0) {
events.push(createArenaEvent(0, time, 'entityDefeated', ROGUELIKE_PRESSURE_SOURCE_ID, member.id))
}
return result.member
})
let nextPulseAt = pressure.nextPulseAt
while (nextPulseAt <= time) nextPulseAt += pressure.intervalSeconds
return {
events,
party: nextParty,
pressure: {
...pressure,
nextPulseAt,
},
}
}
function safeRoguelikeStage(stage: number): number {
return Number.isFinite(stage) ? Math.max(1, Math.floor(stage)) : 1
}
+3 -3
View File
@@ -50,20 +50,20 @@ const SANDGLASS = {
burrowWindup: 0.48,
disengageSeconds: 0.72,
disengageSpeed: 148,
eruptionDamage: 15,
eruptionDamage: 70,
eruptionRadius: 50,
eruptionRecover: 0.52,
eruptionStunSeconds: 0.42,
eruptionWindup: 0.76,
hourglassCooldown: 8.1,
hourglassDamage: 6,
hourglassDamage: 35,
hourglassHazardSeconds: 2.2,
hourglassRadius: 66,
hourglassRecover: 0.48,
hourglassSafeSeconds: 1.05,
hourglassTickSeconds: 0.5,
meleeCooldown: 1.25,
meleeDamage: 5,
meleeDamage: 22,
meleeRange: 58,
moveSpeed: 94,
wallMargin: 70,
+4 -4
View File
@@ -41,14 +41,14 @@ type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
const STORMCOIL_TUNING = {
arcAngleRadians: Math.PI * 0.34,
arcCooldown: 5.6,
arcDamage: 6,
arcDamage: 22,
arcInnerRadius: 78,
arcOuterRadius: 196,
arcRecover: 0.46,
arcStunSeconds: 0.24,
arcWindup: 0.74,
chainCooldown: 7.4,
chainDamage: 5,
chainDamage: 18,
chainLinkRadius: 150,
chainMaxLinks: 7,
chainRecover: 0.5,
@@ -57,14 +57,14 @@ const STORMCOIL_TUNING = {
chainWindup: 0.68,
disengageSpeed: 168,
donutCooldown: 6.2,
donutDamage: 7,
donutDamage: 24,
donutInnerRadius: 64,
donutOuterRadius: 156,
donutRecover: 0.48,
donutStunSeconds: 0.44,
donutWindup: 0.82,
meleeCooldown: 1.12,
meleeDamage: 2,
meleeDamage: 8,
meleeRange: 58,
moveSpeed: 124,
wallContactLimit: 0.72,
+27
View File
@@ -1,5 +1,7 @@
import type { Iwt2BossId } from '../content/bosses'
import type { Iwt2PlayerClassId } from '../content/classes'
import type { Iwt2GearLevel, Iwt2GearSlotId } from '../content/gear'
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
export type Iwt2Vec2 = {
x: number
@@ -47,17 +49,29 @@ export type Iwt2PartyEntityState = {
id: Iwt2PartyEntityId
kind: 'party'
classId: Iwt2PlayerClassId
infusionAbilityId?: Iwt2InfusionAbilityId
aiRole: Iwt2PartyAiRole
position: Iwt2Vec2
velocity: Iwt2Vec2
facing: Iwt2Vec2
radius: number
moveSpeed: number
attackRange: number
attackDamage: number
attackCooldown: number
castTime: number
projectileSpeed: number
health: number
maxHealth: number
damageTakenScale: number
stunTakenScale: number
shieldedDamageTakenMultiplier?: number
shieldedHotHealingMultiplier?: number
shield: number
mana: number
maxMana: number
damageDone: number
gearLevels?: Partial<Record<Iwt2GearSlotId, Iwt2GearLevel>>
attackCooldownRemaining: number
castSecondsRemaining: number
attackReady: boolean
@@ -270,6 +284,8 @@ export type Iwt2GroundHazardState = {
export type Iwt2ArenaEventType =
| 'bossDamaged'
| 'healerSpellCast'
| 'partyAttack'
| 'partyHealed'
| 'partyDamaged'
| 'partyStunned'
@@ -287,6 +303,16 @@ export type Iwt2ArenaEvent = {
value?: number
}
export type Iwt2RoguelikePressureContentType = 'dungeon' | 'raid' | 'stadium'
export type Iwt2RoguelikePressureState = {
stage: number
contentType: Iwt2RoguelikePressureContentType
intervalSeconds: number
damage: number
nextPulseAt: number
}
export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
export type Iwt2ArenaIndicatorBase = {
@@ -354,6 +380,7 @@ export type Iwt2ArenaState = {
boss: Iwt2BossEntityState
bosses: Iwt2BossEntityState[]
indicators: Iwt2ArenaIndicator[]
roguelikePressure?: Iwt2RoguelikePressureState
nextEventId: number
nextProjectileId: number
nextAddId: number
+5 -5
View File
@@ -42,29 +42,29 @@ const VENOM_ORCHID_HYDRA_PHASE = {
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
const VENOM_ORCHID_HYDRA = {
bloomBurstDamage: 7,
bloomBurstDamage: 18,
bloomCooldown: 8.6,
bloomPodCount: 3,
bloomPodDamage: 3,
bloomPodDamage: 10,
bloomPodRadius: 30,
bloomPodSeconds: 1.4,
bloomRecover: 0.5,
bloomWindup: 0.9,
coneAngleRadians: Math.PI * 0.34,
coneCooldown: 5.7,
coneDamage: 10,
coneDamage: 28,
coneRange: 178,
coneRecover: 0.62,
coneStaggerWindup: 0.48,
coneStunSeconds: 0.18,
headOffset: 22,
meleeCooldown: 1.35,
meleeDamage: 4,
meleeDamage: 10,
meleeRange: 58,
moveSpeed: 58,
relocateSpeed: 114,
relocateSeconds: 1.45,
rootLaneDamage: 8,
rootLaneDamage: 22,
rootLaneLengthInset: 36,
rootLaneWidth: 28,
rootRecover: 0.58,
+1 -1
View File
@@ -45,7 +45,7 @@ export type Iwt2YianTickResult = {
const BIRD_COUNT = 3
const BIRD_MELEE_RANGE = 24
const BIRD_MELEE_COOLDOWN = 1.15
const BIRD_FLIGHT_DAMAGE = 10
const BIRD_FLIGHT_DAMAGE = 26
const YIAN_FIREBALL_BOUNCES = 8
const YIAN_SAFE_WALL_MARGIN = 118
const YIAN_CENTER_CAST_DISTANCE = 36
+2 -1
View File
@@ -91,5 +91,6 @@ export function coinDropQuantity() {
}
export function roguelikeCoinItemLevel(stage) {
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
if (stage < 5) return 1
return Math.min(25, 5 + Math.max(1, Math.floor(stage / 5)) * 5)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Some files were not shown because too many files have changed in this diff Show More