diff --git a/IWantToHeal-Thor-v1.1.23.apk b/IWantToHeal-Thor-v1.1.23.apk new file mode 100644 index 0000000..9bd0220 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.23.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 5af9810..d13a1f9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 101 - versionName "1.1.22" + versionCode 102 + versionName "1.1.23" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/db/schema.sql b/db/schema.sql index f40d988..81ddfc7 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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), diff --git a/public/iwt2/bosses/barroth.svg b/public/iwt2/bosses/barroth.svg index c1011f2..7df0f62 100644 --- a/public/iwt2/bosses/barroth.svg +++ b/public/iwt2/bosses/barroth.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/bosses/bulldrome.svg b/public/iwt2/bosses/bulldrome.svg index 3c56432..6362974 100644 --- a/public/iwt2/bosses/bulldrome.svg +++ b/public/iwt2/bosses/bulldrome.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/bosses/great-jaggi.svg b/public/iwt2/bosses/great-jaggi.svg index 3b4c0fb..4c8ee5e 100644 --- a/public/iwt2/bosses/great-jaggi.svg +++ b/public/iwt2/bosses/great-jaggi.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/bosses/khezu.svg b/public/iwt2/bosses/khezu.svg index d46805d..09ac7d2 100644 --- a/public/iwt2/bosses/khezu.svg +++ b/public/iwt2/bosses/khezu.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/bosses/rathian.svg b/public/iwt2/bosses/rathian.svg index 7e7ae1c..152c5ee 100644 --- a/public/iwt2/bosses/rathian.svg +++ b/public/iwt2/bosses/rathian.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/bosses/tobi-kadachi.svg b/public/iwt2/bosses/tobi-kadachi.svg index 4017067..8c04630 100644 --- a/public/iwt2/bosses/tobi-kadachi.svg +++ b/public/iwt2/bosses/tobi-kadachi.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/bosses/yian-kut-ku.svg b/public/iwt2/bosses/yian-kut-ku.svg index fddb80e..a4f14b0 100644 --- a/public/iwt2/bosses/yian-kut-ku.svg +++ b/public/iwt2/bosses/yian-kut-ku.svg @@ -1,4 +1,4 @@ - + diff --git a/public/iwt2/classes/arena/healer-chunky.png b/public/iwt2/classes/arena/healer-chunky.png new file mode 100644 index 0000000..384d21c Binary files /dev/null and b/public/iwt2/classes/arena/healer-chunky.png differ diff --git a/public/iwt2/classes/arena/mage-chunky.png b/public/iwt2/classes/arena/mage-chunky.png new file mode 100644 index 0000000..192d97b Binary files /dev/null and b/public/iwt2/classes/arena/mage-chunky.png differ diff --git a/public/iwt2/classes/arena/paladin-chunky.png b/public/iwt2/classes/arena/paladin-chunky.png new file mode 100644 index 0000000..b7b0d71 Binary files /dev/null and b/public/iwt2/classes/arena/paladin-chunky.png differ diff --git a/public/iwt2/classes/arena/ranger-chunky.png b/public/iwt2/classes/arena/ranger-chunky.png new file mode 100644 index 0000000..0e3f1cb Binary files /dev/null and b/public/iwt2/classes/arena/ranger-chunky.png differ diff --git a/public/iwt2/classes/arena/rogue-chunky.png b/public/iwt2/classes/arena/rogue-chunky.png new file mode 100644 index 0000000..7f9e086 Binary files /dev/null and b/public/iwt2/classes/arena/rogue-chunky.png differ diff --git a/public/iwt2/classes/arena/warrior-chunky.png b/public/iwt2/classes/arena/warrior-chunky.png new file mode 100644 index 0000000..ab2711d Binary files /dev/null and b/public/iwt2/classes/arena/warrior-chunky.png differ diff --git a/public/iwt2/classes/ui/healer-medallion.png b/public/iwt2/classes/ui/healer-medallion.png new file mode 100644 index 0000000..8daea6d Binary files /dev/null and b/public/iwt2/classes/ui/healer-medallion.png differ diff --git a/public/iwt2/classes/ui/mage-medallion.png b/public/iwt2/classes/ui/mage-medallion.png new file mode 100644 index 0000000..812e013 Binary files /dev/null and b/public/iwt2/classes/ui/mage-medallion.png differ diff --git a/public/iwt2/classes/ui/paladin-medallion.png b/public/iwt2/classes/ui/paladin-medallion.png new file mode 100644 index 0000000..bea51df Binary files /dev/null and b/public/iwt2/classes/ui/paladin-medallion.png differ diff --git a/public/iwt2/classes/ui/ranger-medallion.png b/public/iwt2/classes/ui/ranger-medallion.png new file mode 100644 index 0000000..34bcd0a Binary files /dev/null and b/public/iwt2/classes/ui/ranger-medallion.png differ diff --git a/public/iwt2/classes/ui/rogue-medallion.png b/public/iwt2/classes/ui/rogue-medallion.png new file mode 100644 index 0000000..1bc9d95 Binary files /dev/null and b/public/iwt2/classes/ui/rogue-medallion.png differ diff --git a/public/iwt2/classes/ui/warrior-medallion.png b/public/iwt2/classes/ui/warrior-medallion.png new file mode 100644 index 0000000..c1b2b32 Binary files /dev/null and b/public/iwt2/classes/ui/warrior-medallion.png differ diff --git a/public/iwt2/projectiles/mage-fireball.png b/public/iwt2/projectiles/mage-fireball.png new file mode 100644 index 0000000..c4e880f Binary files /dev/null and b/public/iwt2/projectiles/mage-fireball.png differ diff --git a/public/iwt2/projectiles/ranger-arrow.png b/public/iwt2/projectiles/ranger-arrow.png new file mode 100644 index 0000000..3dad477 Binary files /dev/null and b/public/iwt2/projectiles/ranger-arrow.png differ diff --git a/screenshots/iwt2-gear-upgrade-base-960x540.png b/screenshots/iwt2-gear-upgrade-base-960x540.png new file mode 100644 index 0000000..898615f Binary files /dev/null and b/screenshots/iwt2-gear-upgrade-base-960x540.png differ diff --git a/screenshots/iwt2-gear-upgrade-clean-960x540.png b/screenshots/iwt2-gear-upgrade-clean-960x540.png new file mode 100644 index 0000000..7bce628 Binary files /dev/null and b/screenshots/iwt2-gear-upgrade-clean-960x540.png differ diff --git a/screenshots/iwt2-gear-upgrade-paladin-960x540.png b/screenshots/iwt2-gear-upgrade-paladin-960x540.png new file mode 100644 index 0000000..e694fcd Binary files /dev/null and b/screenshots/iwt2-gear-upgrade-paladin-960x540.png differ diff --git a/screenshots/iwt2-menu-final-iwt1-style.png b/screenshots/iwt2-menu-final-iwt1-style.png new file mode 100644 index 0000000..2bec535 Binary files /dev/null and b/screenshots/iwt2-menu-final-iwt1-style.png differ diff --git a/server/game-api.mjs b/server/game-api.mjs index 44d9a39..e78cb38 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -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 diff --git a/src/App.css b/src/App.css index e9c27cd..735713c 100644 --- a/src/App.css +++ b/src/App.css @@ -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,206 @@ 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-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 +1282,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 +1395,7 @@ .iwt2-shell .character-summary { gap: 7px; + margin-left: auto; } .iwt2-shell .character-summary strong { @@ -881,6 +1406,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 +1474,11 @@ padding: 10px 12px; } + .iwt2-gear-screen { + margin-top: 6px; + padding-top: 6px; + } + .iwt2-screen-shell .screen-heading { padding-bottom: 8px; } @@ -958,6 +1498,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 +1552,86 @@ } .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-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 +1711,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 +12631,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; diff --git a/src/App.tsx b/src/App.tsx index b0b6b5e..19707ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -116,7 +116,12 @@ function App() { } if (selectedVersion === 'iwt2') { - return setSelectedVersion(null)} /> + return ( + setSelectedVersion(null)} + /> + ) } return ( diff --git a/src/combat/rewardSummaries.ts b/src/combat/rewardSummaries.ts index 82a3c0b..7090c06 100644 --- a/src/combat/rewardSummaries.ts +++ b/src/combat/rewardSummaries.ts @@ -9,9 +9,13 @@ export type RewardSummaryBase = { unlockedAbilities: DungeonReward['unlockedAbilities'] } +export type RunBossCoinAward = NonNullable & { + sourceLabel: string +} + export type PvpRunRewardSummary = RewardSummaryBase & { bossesKilled: number - loot: Array> + loot: RunBossCoinAward[] } export function createEmptyRewardSummary(): RewardSummaryBase { @@ -60,11 +64,13 @@ export function mergeDungeonRewardSummary( 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, } } diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index b757218..da95f59 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -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({ 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) => ({ @@ -359,6 +360,7 @@ export function CombatScreen({ const [reward, setReward] = useState(null) const [rewardError, setRewardError] = useState('') const [lootRolls, setLootRolls] = useState([]) + const [roguelikeBossCoins, setRoguelikeBossCoins] = useState([]) const [showEndLog, setShowEndLog] = useState(false) const { floatingTexts, @@ -540,9 +542,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 +576,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 +605,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) @@ -1434,7 +1459,11 @@ export function CombatScreen({ {rewardError &&

{rewardError}

} {reward && ( <> + {isRoguelike &&

Run totals

} + {isRoguelike && ( + + )} {!isRoguelike && ( <>

Component tier: item level {reward.droppedItemLevel}.

@@ -1459,6 +1488,7 @@ export function CombatScreen({ <>

{encounterIndex} encounters cleared.

+

{reward.resourceSpent} {gameClass.resourceName} spent {reward.durationSeconds}s survived diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index be247e6..26a57f3 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -229,6 +229,7 @@ function spellResourceCost(spell: Spell, buffs: StackCounts, 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 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) => ({ @@ -537,7 +538,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 +583,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') @@ -1808,11 +1815,12 @@ export function PvPRoguelikeScreen({ >

{finalEncountersCleared} encounters cleared.

+

Run totals

{runSummary.bossesKilled} bosses killed.

{runSummary.bossesKilled > 0 && !reward && !rewardError &&

Final boss rewards still recording...

} {rewardError &&

{rewardError}

} - + {reward && runSummary.bossesKilled === 0 && ( <> diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx index 2dce8c5..5e7ed50 100644 --- a/src/components/PvpStadiumScreen.tsx +++ b/src/components/PvpStadiumScreen.tsx @@ -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(() => createStadiumStarterSide({ partyTemplate, @@ -382,6 +409,7 @@ export function PvpStadiumScreen({ clearFloatingTexts, } = useSidedFloatingCombatText() const [rewardSummary, setRewardSummary] = useState(() => createEmptyRewardSummary()) + const [stadiumBossCoins, setStadiumBossCoins] = useState([]) 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>, 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( - 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({

Opponent

{opponentLabel}

- Survival {formatTime(cpuSide.survivalSeconds)} + {liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}
- {gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE} + {liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({ >

Final score {roundWins.player} - {roundWins.opponent}

+

Run totals

{rewardError &&

{rewardError}

} +
)} diff --git a/src/components/RewardPanels.tsx b/src/components/RewardPanels.tsx index 03f33cd..a684cb0 100644 --- a/src/components/RewardPanels.tsx +++ b/src/components/RewardPanels.tsx @@ -1,4 +1,5 @@ import type { CombatLogEntry } from '../game' +import type { RunBossCoinAward } from '../combat/rewardSummaries' import type { DungeonReward, LootRoll } from '../profile' type BonusItem = NonNullable @@ -119,14 +120,16 @@ export function LootRollList({ export function PvpRunLootList({ loot, + emptyLabel = 'No boss coins awarded', }: { - loot: BonusItem[] + loot: RunBossCoinAward[] + emptyLabel?: string }) { return (
{loot.length > 0 ? loot.map((item, index) => (
- Boss {index + 1} + {item.sourceLabel} {item.glyph} {item.name} x{item.quantity} {item.duplicate ? ` (owned x${item.quantityAfter})` : ''} @@ -134,8 +137,8 @@ export function PvpRunLootList({
)) : (
- Loot - No boss loot awarded + Boss Coins + {emptyLabel}
)}
diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 19713a1..686ef8e 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -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 diff --git a/src/modes/iwt1/IWantToHeal1App.tsx b/src/modes/iwt1/IWantToHeal1App.tsx index a3d9595..2df753f 100644 --- a/src/modes/iwt1/IWantToHeal1App.tsx +++ b/src/modes/iwt1/IWantToHeal1App.tsx @@ -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`, @@ -917,7 +914,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 (
{screen !== 'hunter-profile' && ( @@ -1271,13 +1267,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 ( ) })} diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx index 8949de6..e730839 100644 --- a/src/modes/iwt2/IWantToHeal2App.tsx +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -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('menu') const [save, setSave] = useState(loadIwt2Save) const [selectedIndex, setSelectedIndex] = useState(0) const [selectedBossId, setSelectedBossId] = useState('bulldrome') const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon') + const [arenaDifficulty, setArenaDifficulty] = useState(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('pve') const [roguelikeContentType, setRoguelikeContentType] = useState('dungeon') const [roguelikeRun, setRoguelikeRun] = useState(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( - () => buildIwt2SetupDualScreenState(screen, selectedBossId, save), - [save, screen, selectedBossId], + () => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)), + [save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug], ) const workshopDualScreenState = useMemo( () => buildIwt2WorkshopDualScreenState(screen, save), @@ -191,7 +224,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () return ( setScreen('menu')} @@ -224,6 +258,10 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () }} save={save} onBack={() => setScreen('roguelike')} + onPvpRequeue={() => { + setScreen('roguelike') + startIwt2RoguelikeRun() + }} onSaveUpdated={setSave} /> ) @@ -258,12 +296,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
setScreen('menu')} - onOpenBoss={(bossId) => { - setArenaModeLabel('Dungeon') + onDifficultyChange={setSelectedDungeonDifficultySlug} + onOpenBoss={(bossId, difficulty) => { + setArenaModeLabel(`${difficulty.name} Dungeon`) + setArenaDifficulty(difficulty) setSelectedBossId(bossId) setScreen('arena') }} + onPreviewBoss={setSelectedBossId} />
) @@ -276,20 +319,23 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () 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} />
@@ -301,13 +347,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
setScreen('menu')} - onOpenBoss={(bossId) => { - setArenaModeLabel('Raid') + onDifficultyChange={setSelectedRaidDifficultySlug} + onOpenBoss={(bossId, difficulty) => { + setArenaModeLabel(`${difficulty.name} Raid`) + setArenaDifficulty(difficulty) setSelectedBossId(bossId) setScreen('arena') }} + onPreviewBoss={setSelectedBossId} />
) @@ -321,6 +372,24 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () ) } + if (screen === 'gear-upgrade') { + return ( +
+ setScreen('menu')} + onBackToGameSelect={onBackToGameSelect} + /> + setScreen('menu')} + onSaveUpdated={setSave} + /> +
+ ) + } + if (screen === 'customize-character') { return (
@@ -340,6 +409,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () setScreen('menu')} onSaveUpdated={setSave} /> @@ -362,14 +432,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () {screen === 'menu' && (
-
-

I Want To Heal 2

-

Mode Select

-
{MENU_ITEMS.map((item, index) => ( + {title && {title}}
{save.character.name} IWT2 Level {save.character.level} {save.character.experience} XP
+ {onBack && ( + + )} ) } @@ -557,30 +676,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, diff --git a/src/modes/iwt2/Iwt2BottomDisplay.tsx b/src/modes/iwt2/Iwt2BottomDisplay.tsx index c99cf84..2c03f82 100644 --- a/src/modes/iwt2/Iwt2BottomDisplay.tsx +++ b/src/modes/iwt2/Iwt2BottomDisplay.tsx @@ -42,6 +42,7 @@ export function Iwt2BottomDisplay() { binding={activeBindings[action]} iconStyle="playstation" />{' '} + {label}
) diff --git a/src/modes/iwt2/components/PartyFrames.tsx b/src/modes/iwt2/components/PartyFrames.tsx index f28ec33..c5018ac 100644 --- a/src/modes/iwt2/components/PartyFrames.tsx +++ b/src/modes/iwt2/components/PartyFrames.tsx @@ -40,7 +40,9 @@ export function PartyFrames({ /> )} - {meta.icon} + + +
{meta.name} diff --git a/src/modes/iwt2/content/bossRewards.ts b/src/modes/iwt2/content/bossRewards.ts index 65e8145..3c64593 100644 --- a/src/modes/iwt2/content/bossRewards.ts +++ b/src/modes/iwt2/content/bossRewards.ts @@ -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 = { - '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 = { + 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 = { + '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 = { + '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 = { - '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] +} diff --git a/src/modes/iwt2/content/bosses.ts b/src/modes/iwt2/content/bosses.ts index 890ea7c..39710ff 100644 --- a/src/modes/iwt2/content/bosses.ts +++ b/src/modes/iwt2/content/bosses.ts @@ -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, @@ -198,7 +198,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = { radius: 29, moveSpeed: 118, meleeRange: 56, - meleeDamage: 8, + meleeDamage: 12, meleeCooldown: 1, chargeCooldown: 0, chargeWindup: 0, @@ -215,10 +215,10 @@ 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, @@ -226,14 +226,14 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = { birdFlightSpeed: 360, birdHealth: 58, 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, diff --git a/src/modes/iwt2/content/classes.ts b/src/modes/iwt2/content/classes.ts index 5bf5905..db257b2 100644 --- a/src/modes/iwt2/content/classes.ts +++ b/src/modes/iwt2/content/classes.ts @@ -7,6 +7,8 @@ export type Iwt2ClassMetadata = { name: string role: Iwt2PlayerClassRole icon: string + arenaSpriteUrl: string + uiIconUrl: string color: string accentColor: string maxHealth: number @@ -25,6 +27,8 @@ export const IWT2_CLASS_METADATA: Record = 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 +45,8 @@ export const IWT2_CLASS_METADATA: Record = 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 +63,8 @@ export const IWT2_CLASS_METADATA: Record = 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 +81,8 @@ export const IWT2_CLASS_METADATA: Record = 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 +99,8 @@ export const IWT2_CLASS_METADATA: Record = 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 +117,8 @@ export const IWT2_CLASS_METADATA: Record = name: 'Warrior', role: 'damage', icon: '⚔', + arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png', + uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png', color: '#ff7675', accentColor: '#ffd0d0', maxHealth: 150, diff --git a/src/modes/iwt2/content/difficulties.ts b/src/modes/iwt2/content/difficulties.ts new file mode 100644 index 0000000..3896388 --- /dev/null +++ b/src/modes/iwt2/content/difficulties.ts @@ -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 +} diff --git a/src/modes/iwt2/content/gear.ts b/src/modes/iwt2/content/gear.ts new file mode 100644 index 0000000..e989198 --- /dev/null +++ b/src/modes/iwt2/content/gear.ts @@ -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 + infusionAbilityId: Iwt2InfusionAbilityId | null +} + +export type Iwt2GearProgress = Record + +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 = { + weapon: 'Weapon', + helmet: 'Helmet', + chest: 'Chest', + legs: 'Legs', + feet: 'Feet', +} + +export const IWT2_GEAR_STAT_LABELS: Record = { + 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> = { + 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 + 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): string { + if (level <= 2) return 'initiate' + if (level === 3) return 'veteran' + if (level === 4) return 'champion' + return 'mythic' +} diff --git a/src/modes/iwt2/content/healerAbilities.ts b/src/modes/iwt2/content/healerAbilities.ts index c474e51..fbd2892 100644 --- a/src/modes/iwt2/content/healerAbilities.ts +++ b/src/modes/iwt2/content/healerAbilities.ts @@ -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 = { @@ -55,7 +66,6 @@ export const IWT2_HEALER_ABILITIES: Record = 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 = 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 = 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 { diff --git a/src/modes/iwt2/content/healerInfusionAbilities.ts b/src/modes/iwt2/content/healerInfusionAbilities.ts new file mode 100644 index 0000000..5196cc3 --- /dev/null +++ b/src/modes/iwt2/content/healerInfusionAbilities.ts @@ -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', + } +} diff --git a/src/modes/iwt2/content/infusionAbilities.ts b/src/modes/iwt2/content/infusionAbilities.ts new file mode 100644 index 0000000..48841bb --- /dev/null +++ b/src/modes/iwt2/content/infusionAbilities.ts @@ -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 = { + '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, + } +} diff --git a/src/modes/iwt2/content/roguelike.ts b/src/modes/iwt2/content/roguelike.ts index 7903935..0808879 100644 --- a/src/modes/iwt2/content/roguelike.ts +++ b/src/modes/iwt2/content/roguelike.ts @@ -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 = { } 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 = { id: 'revive-party-members', @@ -33,6 +63,150 @@ export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice> = [ + { + 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> = [ + { + 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> = [ + { + 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({ + const baseChoices = buildSelfSlotUpgradeChoices({ 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[]) { diff --git a/src/modes/iwt2/render/bossAnimation.ts b/src/modes/iwt2/render/bossAnimation.ts new file mode 100644 index 0000000..2f41b73 --- /dev/null +++ b/src/modes/iwt2/render/bossAnimation.ts @@ -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> = { + 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') + ) +} diff --git a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts index 8c9cfd3..6acc34d 100644 --- a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts +++ b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts @@ -1,8 +1,10 @@ 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 type { + Iwt2ArenaEvent, Iwt2ArenaIndicator, Iwt2ArenaState, Iwt2BossEntityState, @@ -20,6 +22,11 @@ type SceneDeps = { } type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState +type FloatingCombatTextAnchor = { + kind: DrawableEntity['kind'] + position: Iwt2Vec2 + radius: number +} export class BulldromeArenaScene extends Phaser.Scene { private deps: SceneDeps @@ -29,6 +36,13 @@ export class BulldromeArenaScene extends Phaser.Scene { private telegraphGraphics?: Phaser.GameObjects.Graphics private labels = new Map() private bossSprites = new Map() + private partySprites = new Map() + private projectileSprites = new Map() + private floatingCombatTexts = new Map() + private castGlowEffects = new Set() + private lastEntityAnchors = new Map() + private lastFloatingEventId = 0 + private lastStateTime = 0 private lastHudPublish = 0 constructor(deps: SceneDeps) { @@ -44,6 +58,11 @@ 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) + } + this.load.image(projectileSpriteKey('arrow'), '/iwt2/projectiles/ranger-arrow.png') + this.load.image(projectileSpriteKey('fireball'), '/iwt2/projectiles/mage-fireball.png') } create() { @@ -66,6 +85,7 @@ export class BulldromeArenaScene extends Phaser.Scene { this.drawTelegraphs(state) this.drawEntities(state) this.drawProjectiles(state) + this.drawFloatingCombatTexts(state) this.lastHudPublish += 1 } @@ -99,8 +119,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(entities.filter((entity) => entity.kind !== 'boss').map((entity) => entity.id)) + const liveLabelIds = new Set(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id)) const liveBossIds = new Set(state.bosses.map((boss) => boss.id)) + const livePartyIds = new Set(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 +130,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 +155,21 @@ 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) + if (this.textures.exists(classArenaSpriteKey(entity.classId))) { + drawPartyUnderlay(graphics, entity, color, alpha) + this.drawPartySprite(entity, alpha) + } 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 +180,13 @@ 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, label] of this.labels) { if (!liveLabelIds.has(id)) { label.destroy() @@ -164,16 +197,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 +245,41 @@ 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 drawPartySprite(entity: Iwt2PartyEntityState, alpha: 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 displayHeight = entity.radius * 4.55 + sprite + .setTexture(key) + .setPosition(entity.position.x, entity.position.y + entity.radius * 0.18) + .setDisplaySize(displayHeight * (sprite.width / Math.max(1, sprite.height)), displayHeight) + .setFlipX(entity.facing.x < -0.05) + .setAlpha(alpha) + } + 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 +290,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 +324,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 { @@ -268,6 +436,72 @@ function bossSpriteKey(bossId: Iwt2BossId): string { return `iwt2-boss-${bossId}` } +function classArenaSpriteKey(classId: Iwt2PlayerClassId): string { + return `iwt2-class-${classId}` +} + +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 { + const anchors = new Map() + 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, diff --git a/src/modes/iwt2/save/iwt2Repository.ts b/src/modes/iwt2/save/iwt2Repository.ts index 0466c08..06ee57e 100644 --- a/src/modes/iwt2/save/iwt2Repository.ts +++ b/src/modes/iwt2/save/iwt2Repository.ts @@ -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 - if (candidate.version !== 1) return createDefaultIwt2Save() + const candidate = value as Partial> & { 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 { + 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 { + 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>, @@ -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> + 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> + 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() + for (const rawItem of value) { + if (!rawItem || typeof rawItem !== 'object') continue + const item = rawItem as Partial + 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 { + if (!value || typeof value !== 'object') return {} + const next: Record = {} + for (const [rawId, rawQuantity] of Object.entries(value as Record)) { + 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' +} diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx index cde4223..d229cdc 100644 --- a/src/modes/iwt2/screens/BossArenaScreen.tsx +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -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,44 @@ 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 + onPvpRequeue?: () => void onSaveUpdated: (save: Iwt2Save) => void roguelikeRun?: { buffs: Iwt2RoguelikeSelfBuffId[] @@ -63,18 +84,49 @@ type BossArenaScreenProps = { } } -export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) { +export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, 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(() => 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(() => createArenaState( + bossId, + bossIds, + combinedBossHealthScale, + combinedDamageScale, + roguelikeBuffs, + createPressureState(roguelikeStage, roguelikeContentType), + pveGearActive ? save.gearProgress : undefined, + )) const [opponentArenaState, setOpponentArenaState] = useState(() => ( - pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null + pvpRoguelike + ? createInitialIwt2ArenaState( + bossId, + bossIds, + combinedBossHealthScale, + combinedDamageScale, + createPressureState(roguelikeStage, roguelikeContentType), + ) + : null )) const [abilityCooldowns, setAbilityCooldowns] = useState>({}) const [status, setStatus] = useState('playing') const [selectedOverlayAction, setSelectedOverlayAction] = useState('primary') const [selectedPartyId, setSelectedPartyId] = useState('player-healer') + const [dropAwards, setDropAwards] = useState([]) const [petAwards, setPetAwards] = useState([]) const stateRef = useRef(arenaState) const opponentStateRef = useRef(opponentArenaState) @@ -121,8 +173,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,9 +200,11 @@ 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) => { setSelectedOverlayAction('primary') @@ -141,12 +212,21 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa }, []) 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 +248,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,9 +260,13 @@ 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') { onBack() return @@ -195,7 +280,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa return } resetArena() - }, [onBack, resetArena, roguelikeRun]) + }, [onBack, onPvpRequeue, resetArena, roguelikeRun]) useGameAction((action, device) => { if (device === 'controller' && statusRef.current !== 'playing') { @@ -273,21 +358,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 +402,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 +411,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` @@ -463,9 +563,13 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
{status === 'victory' && (
- +{arenaState.bosses.length * 125} XP - {arenaState.bosses.length} carves + +{victoryExperience} XP Log +{arenaState.bosses.length} + {dropAwards.map((award) => ( + + {award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''} + + ))} {petAwards.map((award) => ( {award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''} @@ -478,14 +582,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} + {pvpRoguelike && ( + + )} + ))} +
+ ) +} + +export function Iwt2DungeonsScreen({ + difficultySlug, + onBack, + onDifficultyChange, + onOpenBoss, + onPreviewBoss, + save, +}: { + difficultySlug: string onBack: () => void - onOpenBoss: (bossId: Iwt2BossId) => void + onDifficultyChange: (difficultySlug: string) => void + onOpenBoss: (bossId: Iwt2BossId, difficulty: Iwt2Difficulty) => void + onPreviewBoss: (bossId: Iwt2BossId) => void + save: Iwt2Save }) { const [page, setPage] = useState(0) + const [selectedKey, setSelectedKey] = useState('bulldrome') + const selectedDifficulty = findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, difficultySlug) + const difficultyLocked = !isIwt2DifficultyUnlocked(selectedDifficulty, save.character.level) const pageCount = Math.max(1, Math.ceil(IWT2_DUNGEON_ACTIONS.length / IWT2_DUNGEON_BOSS_PAGE_SIZE)) const currentPage = Math.min(page, pageCount - 1) const pageStart = currentPage * IWT2_DUNGEON_BOSS_PAGE_SIZE const pageEnd = Math.min(IWT2_DUNGEON_ACTIONS.length, pageStart + IWT2_DUNGEON_BOSS_PAGE_SIZE) - const actions = useMemo(() => withBackAction([ + const visibleBosses = useMemo( + () => IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd), + [pageEnd, pageStart], + ) + const difficultyOptions = useMemo(() => ( + IWT2_DUNGEON_DIFFICULTIES.map((difficulty) => ({ + ...difficulty, + locked: !isIwt2DifficultyUnlocked(difficulty, save.character.level), + selected: difficulty.slug === selectedDifficulty.slug, + })) + ), [save.character.level, selectedDifficulty.slug]) + const navEntries = useMemo(() => [ { key: 'dungeons-page-prev', label: 'Previous Page', - detail: `Bosses ${pageStart + 1}-${pageEnd} of ${IWT2_DUNGEON_ACTIONS.length}`, disabled: currentPage === 0, - value: 'Prev', onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)), }, { key: 'dungeons-page-next', label: 'Next Page', - detail: `Page ${currentPage + 1}/${pageCount}`, disabled: currentPage >= pageCount - 1, - value: 'Next', onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)), }, - ...IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd).map((entry) => ({ - ...entry, - onConfirm: () => onOpenBoss(entry.bossId), + { + key: 'back', + label: 'Back', + onConfirm: onBack, + }, + ...difficultyOptions.map((difficulty) => ({ + key: `difficulty-${difficulty.slug}`, + label: difficulty.name, + detail: difficulty.description, + value: difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`, + disabled: difficulty.locked, + onConfirm: () => onDifficultyChange(difficulty.slug), })), - ], onBack), [currentPage, onBack, onOpenBoss, pageCount, pageEnd, pageStart]) + ...visibleBosses.map((entry) => { + const reward = iwt2BossCoinRewardFor(entry.bossId, selectedDifficulty.slug) + return { + ...entry, + detail: `${entry.detail} ${selectedDifficulty.name}: ${selectedDifficulty.healthMultiplier.toFixed(2)}x HP, ${selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: ${reward.name}.`, + disabled: difficultyLocked, + value: difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name, + onConfirm: () => onOpenBoss(entry.bossId, selectedDifficulty), + } + }), + ], [ + currentPage, + difficultyLocked, + difficultyOptions, + onBack, + onDifficultyChange, + onOpenBoss, + pageCount, + selectedDifficulty, + visibleBosses, + ]) + const activeEntry = navEntries.find((entry) => entry.key === selectedKey && !entry.disabled) + ?? navEntries.find((entry) => entry.key === visibleBosses[0]?.key) + ?? navEntries.find((entry) => !entry.disabled) + + function selected(key: string) { + return activeEntry?.key === key + } + + function openDungeonNavEntry(entry: Iwt2NavAction | undefined) { + if (!entry || entry.disabled) return + entry.onConfirm() + } + + function moveDungeonSelection(action: InputAction) { + if (!action.startsWith('navigate')) return + const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry?.key)) + const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 + const nextIndex = nextEnabledIndex(navEntries, activeIndex, direction) + const nextKey = navEntries[nextIndex]?.key ?? activeEntry?.key ?? 'back' + setSelectedKey(nextKey) + const nextBoss = visibleBosses.find((boss) => boss.key === nextKey) + if (nextBoss) onPreviewBoss(nextBoss.bossId) + } + + useGameAction((action, device) => { + if (device !== 'controller') return + if (action === 'back') { + onBack() + return + } + if (action === 'confirm') { + openDungeonNavEntry(activeEntry) + return + } + moveDungeonSelection(action) + }) return ( - -
- +
+
+
+

I Want To Heal 2

+

Dungeons

+
+
+ + {currentPage + 1} / {pageCount} + + +
- + { + setSelectedKey(`difficulty-${difficulty.slug}`) + onDifficultyChange(difficulty.slug) + }} + selected={selected} + /> +
+
+ {visibleBosses.map((boss) => ( + + ))} +
+
+
) } export function Iwt2ModeScreen({ + difficultySlug, mode, onBack, + onDifficultyChange, onOpenBoss, + onPreviewBoss, + save, }: { + difficultySlug: string mode: Iwt2Mode onBack: () => void - onOpenBoss: (bossId: Iwt2BossId) => void + onDifficultyChange: (difficultySlug: string) => void + onOpenBoss: (bossId: Iwt2BossId, difficulty: Iwt2Difficulty) => void + onPreviewBoss: (bossId: Iwt2BossId) => void + save: Iwt2Save }) { + const [selectedKey, setSelectedKey] = useState('back') + const selectedDifficulty = findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, difficultySlug) + const difficultyLocked = !isIwt2DifficultyUnlocked(selectedDifficulty, save.character.level) + const difficultyOptions = useMemo(() => ( + IWT2_RAID_DIFFICULTIES.map((difficulty) => ({ + ...difficulty, + locked: !isIwt2DifficultyUnlocked(difficulty, save.character.level), + selected: difficulty.slug === selectedDifficulty.slug, + })) + ), [save.character.level, selectedDifficulty.slug]) const actions = useMemo( - () => withBackAction(MODE_ACTIONS[mode].map((entry) => ({ - key: `${mode}-${entry.bossId}`, - label: entry.label, - detail: entry.detail, - value: entry.value, - onConfirm: () => onOpenBoss(entry.bossId), - })), onBack), - [mode, onBack, onOpenBoss], + () => withBackAction([ + ...difficultyOptions.map((difficulty) => ({ + key: `difficulty-${difficulty.slug}`, + label: difficulty.name, + detail: difficulty.description, + value: difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`, + disabled: difficulty.locked, + onConfirm: () => onDifficultyChange(difficulty.slug), + })), + ...MODE_ACTIONS[mode].map((entry) => { + const reward = iwt2BossCoinRewardFor(entry.bossId, selectedDifficulty.slug) + return { + key: `${mode}-${entry.bossId}`, + label: entry.label, + detail: `${entry.detail} ${selectedDifficulty.name}: ${selectedDifficulty.healthMultiplier.toFixed(2)}x HP, ${selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: ${reward.name}.`, + value: difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name, + disabled: difficultyLocked, + onConfirm: () => onOpenBoss(entry.bossId, selectedDifficulty), + } + }), + ], onBack), + [difficultyLocked, difficultyOptions, mode, onBack, onDifficultyChange, onOpenBoss, selectedDifficulty], ) + const activeEntry = actions.find((entry) => entry.key === selectedKey && !entry.disabled) + ?? actions.find((entry) => !entry.disabled) + const selected = (key: string) => activeEntry?.key === key + + useGameAction((action, device) => { + if (device !== 'controller') return + if (action === 'back') { + onBack() + return + } + if (action === 'confirm') { + activeEntry?.onConfirm() + return + } + if (!action.startsWith('navigate')) return + const activeIndex = Math.max(0, actions.findIndex((entry) => entry.key === activeEntry?.key)) + const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1 + const nextIndex = nextEnabledIndex(actions, activeIndex, direction) + const nextKey = actions[nextIndex]?.key ?? activeEntry?.key ?? 'back' + setSelectedKey(nextKey) + const nextBoss = MODE_ACTIONS[mode].find((entry) => `${mode}-${entry.bossId}` === nextKey) + if (nextBoss) onPreviewBoss(nextBoss.bossId) + }) return ( - + { + setSelectedKey(`difficulty-${difficulty.slug}`) + onDifficultyChange(difficulty.slug) + }} + selected={selected} + /> +
+ {actions.filter((action) => !action.key.startsWith('difficulty-')).map((action) => ( + + ))} +
) } @@ -517,17 +853,21 @@ export function Iwt2ModeScreen({ export function Iwt2RoguelikeScreen({ contentType, onBack, + onCancelQueue, onContentTypeChange, onStart, onVariantChange, + queueing = false, queueMessage, variant, }: { contentType: Iwt2RoguelikeContentType onBack: () => void + onCancelQueue: () => void onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void onStart: () => void onVariantChange: (variant: Iwt2RoguelikeVariant) => void + queueing?: boolean queueMessage?: string variant: Iwt2RoguelikeVariant }) { @@ -615,6 +955,10 @@ export function Iwt2RoguelikeScreen({ useGameAction((action, device) => { if (device !== 'controller') return + if (queueing) { + if (action === 'back' || action === 'confirm') onCancelQueue() + return + } if (action === 'back') onBack() else if (action.startsWith('navigate')) { setSelectedIndex((current) => moveSpatialSelection(actions, current, action)) @@ -771,6 +1115,25 @@ export function Iwt2RoguelikeScreen({
)} + {queueing && ( +
+
+
P V P
+

Matchmaking

+

Queuing for PvP

+ {queueMessage || 'Searching for an opponent...'} + +
+
+ )}
) } @@ -1012,13 +1375,15 @@ export function Iwt2HunterProfileScreen({ const { enabled: dualScreenEnabled } = useDualScreen() const bosses = useMemo(() => ( (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => { - const drop = iwt2BossMaterialRewardFor(bossId) + const coins = iwt2BossCoinRewardsFor(bossId).map((coin) => ({ + ...coin, + quantity: save.collectionLog.dropsFound[coin.id] ?? 0, + })) const pet = iwt2BossPetRewardFor(bossId) return { bossId, - dropId: drop.id, - dropName: drop.name, - drops: save.collectionLog.dropsFound[drop.id] ?? 0, + coins, + drops: coins.reduce((total, coin) => total + coin.quantity, 0), encounter: IWT2_BOSS_METADATA[bossId], kills: save.collectionLog.bossKills[bossId] ?? 0, petId: pet.id, @@ -1048,15 +1413,15 @@ export function Iwt2HunterProfileScreen({ rarity: 'stat', source: selectedBoss.encounter.name, }, - { - chance: 'Guaranteed', - glyph: selectedBoss.encounter.icon, - key: `drop:${selectedBoss.dropId}`, - name: selectedBoss.dropName, - quantity: selectedBoss.drops, - rarity: 'common', + ...selectedBoss.coins.map((coin) => ({ + chance: `Guaranteed 1-3 | iLvl ${coin.itemLevel}`, + glyph: coin.glyph, + key: `drop:${coin.id}`, + name: coin.name, + quantity: coin.quantity, + rarity: coin.rarity, source: selectedBoss.encounter.name, - }, + })), { chance: '1 in 500', glyph: '*', @@ -1362,6 +1727,7 @@ export function Iwt2CustomizeCharacterScreen({ onSaveUpdated: (save: Iwt2Save) => void save: Iwt2Save }) { + const [editingName, setEditingName] = useState(false) const activeAbilities = abilitiesForHealer(save.character.healerStyle) const abilityRows = activeAbilities.map((ability) => ({ key: ability.id, @@ -1370,6 +1736,13 @@ export function Iwt2CustomizeCharacterScreen({ value: ability.icon, })) const actions = useMemo(() => withBackAction([ + { + key: 'profile-name', + label: 'Profile Name', + detail: 'IWT2 hunter callsign shown in header.', + value: save.character.name, + onConfirm: () => setEditingName(true), + }, ...IWT2_HEALER_ORDER.map((healerId) => { const healer = IWT2_HEALER_METADATA[healerId] return { @@ -1384,6 +1757,21 @@ export function Iwt2CustomizeCharacterScreen({ }), ], onBack), [onBack, onSaveUpdated, save]) + if (editingName) { + return ( + setEditingName(false)}> + setEditingName(false)} + onSave={(name) => { + onSaveUpdated(updateIwt2CharacterSettings(save, { name })) + setEditingName(false) + }} + /> + + ) + } + return (
@@ -1404,7 +1792,220 @@ export function Iwt2CustomizeCharacterScreen({ ) } +function Iwt2ProfileNameEditor({ + name, + onCancel, + onSave, +}: { + name: string + onCancel: () => void + onSave: (name: string) => void +}) { + const [draft, setDraft] = useState(name) + const [selectedIndex, setSelectedIndex] = useState(0) + const entries = useMemo(() => [ + ...IWT2_NAME_CHARACTERS.map((character) => ({ + key: `char-${character}`, + label: character, + onConfirm: () => setDraft((current) => `${current}${character}`.slice(0, IWT2_NAME_MAX_LENGTH)), + })), + { + key: 'space', + label: 'Space', + onConfirm: () => setDraft((current) => `${current} `.slice(0, IWT2_NAME_MAX_LENGTH)), + }, + { + key: 'dash', + label: '-', + onConfirm: () => setDraft((current) => `${current}-`.slice(0, IWT2_NAME_MAX_LENGTH)), + }, + { + key: 'erase', + label: 'Erase', + onConfirm: () => setDraft((current) => current.slice(0, -1)), + }, + { + key: 'save', + label: 'Save', + onConfirm: () => onSave(draft), + }, + { + key: 'cancel', + label: 'Cancel', + onConfirm: onCancel, + }, + ], [draft, onCancel, onSave]) + + useGameAction((action, device) => { + if (device !== 'controller') return + if (action === 'back') { + onCancel() + return + } + if (action === 'confirm') { + entries[selectedIndex]?.onConfirm() + return + } + if (action === 'navigateLeft') { + setSelectedIndex((current) => Math.max(0, current - 1)) + } else if (action === 'navigateRight') { + setSelectedIndex((current) => Math.min(entries.length - 1, current + 1)) + } else if (action === 'navigateUp') { + setSelectedIndex((current) => Math.max(0, current - IWT2_NAME_EDITOR_COLUMNS)) + } else if (action === 'navigateDown') { + setSelectedIndex((current) => Math.min(entries.length - 1, current + IWT2_NAME_EDITOR_COLUMNS)) + } + }) + + return ( +
+ +
+ {entries.map((entry, index) => ( + + ))} +
+
+ ) +} + export function Iwt2CloudSaveScreen({ + onBack, + onSaveUpdated, + onlineBackupsAvailable, + save, +}: { + onBack: () => void + onSaveUpdated: (save: Iwt2Save) => void + onlineBackupsAvailable: boolean + save: Iwt2Save +}) { + const [onlineSave, setOnlineSave] = useState(null) + const [syncingOnlineSave, setSyncingOnlineSave] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + let cancelled = false + if (!onlineBackupsAvailable) { + setOnlineSave(null) + setMessage('Online save unavailable in offline mode.') + return () => { + cancelled = true + } + } + setSyncingOnlineSave(true) + setMessage('Checking online save...') + loadIwt2OnlineSave() + .then((result) => { + if (cancelled) return + setOnlineSave(result.save) + setMessage(result.save ? 'Online save loaded.' : 'No online save yet.') + }) + .catch((reason) => { + if (cancelled) return + setOnlineSave(null) + setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.') + }) + .finally(() => { + if (!cancelled) setSyncingOnlineSave(false) + }) + return () => { + cancelled = true + } + }, [onlineBackupsAvailable]) + + const useLocalSave = useCallback(async () => { + if (!onlineBackupsAvailable) { + setMessage('Using local save. Sign in online to update the server copy.') + return + } + setSyncingOnlineSave(true) + setMessage('Uploading local save...') + try { + const result = await writeIwt2OnlineSave(save) + setOnlineSave(result.save) + setMessage('Online save now uses local progress.') + } catch (reason) { + setMessage(reason instanceof Error ? reason.message : 'Unable to upload local save.') + } finally { + setSyncingOnlineSave(false) + } + }, [onlineBackupsAvailable, save]) + + const useOnlineSave = useCallback(() => { + if (!onlineSave) return + onSaveUpdated(onlineSave) + setMessage('Local save now uses online progress.') + }, [onlineSave, onSaveUpdated]) + + const useNewSave = useCallback(() => { + onSaveUpdated(createDefaultIwt2Save()) + setMessage('Started a new local IWT2 save.') + }, [onSaveUpdated]) + + const actions = useMemo(() => withBackAction([ + { + key: 'use-local', + label: 'Use Local Save', + detail: formatIwt2SaveSummary(save), + value: onlineBackupsAvailable ? 'Upload' : 'Current', + disabled: syncingOnlineSave, + onConfirm: () => { + void useLocalSave() + }, + }, + { + key: 'use-online', + label: 'Use Online Save', + detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave), + value: onlineSave ? 'Download' : 'Empty', + disabled: syncingOnlineSave || !onlineSave, + onConfirm: useOnlineSave, + }, + { + key: 'new-save', + label: 'Use New Save File', + detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.', + value: 'New', + onConfirm: useNewSave, + }, + ], onBack), [ + onlineBackupsAvailable, + onlineSave, + onBack, + save, + syncingOnlineSave, + useLocalSave, + useNewSave, + useOnlineSave, + ]) + + return ( + + {message &&

{message}

} + +
+ ) +} + +export function Iwt2GearUpgradeScreen({ onBack, onSaveUpdated, save, @@ -1413,43 +2014,237 @@ export function Iwt2CloudSaveScreen({ onSaveUpdated: (save: Iwt2Save) => void save: Iwt2Save }) { - const savedAt = new Date(save.updatedAt).toLocaleString() - const cloudSavedAt = save.cloudSlot - ? new Date(save.cloudSlot.savedAt).toLocaleString() - : 'No snapshot' - const actions = useMemo(() => withBackAction([ - { - key: 'snapshot', - label: 'Update Local Backup Slot', - detail: 'Copies current IWT2 character, inventory, and collection log into the isolated backup slot.', - value: 'Save', - onConfirm: () => onSaveUpdated(snapshotIwt2CloudSlot(save)), - }, - { - key: 'restore', - label: 'Restore Local Backup Slot', - detail: cloudSavedAt, - value: save.cloudSlot ? 'Restore' : 'Empty', - disabled: !save.cloudSlot, - onConfirm: () => onSaveUpdated(restoreIwt2CloudSlot(save)), - }, - { - key: 'last-save', - label: 'Last Local Save', - detail: savedAt, - value: 'Local', - onConfirm: () => {}, - disabled: true, - }, - ], onBack), [cloudSavedAt, onBack, onSaveUpdated, save, savedAt]) + const [selectedClassId, setSelectedClassId] = useState('healer') + const [selectedSlotId, setSelectedSlotId] = useState('weapon') + const [selectedIndex, setSelectedIndex] = useState(1) + const [message, setMessage] = useState('Gear upgrades use IWT2 boss coins only.') + const classProgress = save.gearProgress[selectedClassId] + const selectedSlot = classProgress.slots[selectedSlotId] + const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId] + const selectedClassName = gearClassDisplayName(selectedClassId, save) + const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level) + const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts) + const infusionUnlocked = isIwt2InfusionUnlocked(classProgress) + const infusionAnchorSlot = selectedSlot.level >= 5 + ? selectedSlotId + : IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId + const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId) + const navEntries = useMemo(() => { + const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }] + IWT2_PARTY_ORDER.forEach((classId, index) => { + entries.push({ kind: 'class', key: `class:${classId}`, row: index + 1, column: 0, classId }) + }) + IWT2_GEAR_SLOTS.forEach((slotId, index) => { + entries.push({ kind: 'slot', key: `slot:${slotId}`, row: index + 1, column: 1, slotId }) + }) + entries.push({ kind: 'upgrade', key: 'upgrade', row: 6, column: 1, disabled: !canUpgrade }) + infusionAbilities.forEach((ability, index) => { + const costs = iwt2InfusionCosts(selectedClassId, infusionAnchorSlot, ability.id) + const selected = classProgress.infusionAbilityId === ability.id + entries.push({ + kind: 'infusion', + key: `infusion:${ability.id}`, + row: index + 1, + column: 2, + abilityId: ability.id, + disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs), + }) + }) + return entries + }, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId]) + + const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0] + + useGameAction((action, device) => { + if (device !== 'controller') return + if (action === 'back') { + onBack() + return + } + if (action.startsWith('navigate')) { + setSelectedIndex((current) => moveGearSelection(navEntries, current, action)) + return + } + if (action === 'confirm' && activeEntry) { + activateEntry(activeEntry) + } + }) + + function activateEntry(entry: Iwt2GearNavEntry) { + if (entry.kind === 'back') { + onBack() + return + } + if (entry.kind === 'class') { + setSelectedClassId(entry.classId) + return + } + if (entry.kind === 'slot') { + setSelectedSlotId(entry.slotId) + return + } + if (entry.kind === 'upgrade') { + if (entry.disabled) return + try { + const nextSave = upgradeIwt2GearSlot(save, selectedClassId, selectedSlotId) + onSaveUpdated(nextSave) + setMessage(`${selectedClassName} ${IWT2_GEAR_SLOT_LABELS[selectedSlotId]} upgraded to +${selectedSlot.level + 1}.`) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Upgrade failed.') + } + return + } + if (entry.kind === 'infusion') { + if (entry.disabled) return + try { + const nextSave = setIwt2InfusionAbility(save, selectedClassId, infusionAnchorSlot, entry.abilityId) + onSaveUpdated(nextSave) + setMessage(`${IWT2_INFUSION_ABILITIES[entry.abilityId].name} infused into slot 6.`) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Infusion failed.') + } + } + } return ( - - - +
+
+
+
+ {IWT2_PARTY_ORDER.map((classId) => { + const metadata = IWT2_CLASS_METADATA[classId] + const selected = selectedClassId === classId + const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId + const classInfusion = save.gearProgress[classId].infusionAbilityId + const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level)) + const className = gearClassDisplayName(classId, save) + const classSubtitle = gearClassSubtitle(classId, save) + return ( + + ) + })} +
+
+ +
+
+ {IWT2_GEAR_SLOTS.map((slotId) => { + const recipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][slotId] + const slot = classProgress.slots[slotId] + const focused = activeEntry?.kind === 'slot' && activeEntry.slotId === slotId + const selected = selectedSlotId === slotId + return ( + + ) + })} +
+ +
+ +
+
+

+ {IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level} + {IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins. +

+
+ {upgradeCosts.length === 0 ? ( + Max rank reached. + ) : upgradeCosts.map((cost) => { + const owned = inventoryQuantity(save.inventory, cost.itemId) + return ( + = cost.quantity ? 'met' : 'missing'} key={cost.itemId}> + {owned}/{cost.quantity} {cost.itemName} + + ) + })} +
+
+ +
+ {infusionAbilities.map((ability) => { + const selected = classProgress.infusionAbilityId === ability.id + const focused = activeEntry?.kind === 'infusion' && activeEntry.abilityId === ability.id + const costs = iwt2InfusionCosts(selectedClassId, infusionAnchorSlot, ability.id) + const disabled = selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs) + return ( + + ) + })} +
+
{message}
+
+
+
) } +function gearClassDisplayName(classId: Iwt2PlayerClassId, save: Iwt2Save): string { + if (classId === 'healer') return save.character.name + return IWT2_CLASS_METADATA[classId].name +} + +function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string { + if (classId === 'healer') return IWT2_HEALER_METADATA[save.character.healerStyle].name + const role = IWT2_CLASS_METADATA[classId].role + return role === 'damage' ? 'Damage' : 'Tank' +} + function clampToEnabled(actions: Iwt2NavAction[], index: number) { if (actions.length === 0) return 0 const bounded = Math.min(Math.max(0, index), actions.length - 1) @@ -1474,6 +2269,58 @@ function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: return clampToEnabled(actions, current) } +function moveGearSelection(entries: Iwt2GearNavEntry[], current: number, action: string): number { + if (entries.length === 0) return 0 + const active = entries[Math.min(current, entries.length - 1)] ?? entries[0] + const candidates = entries + .map((entry, index) => ({ entry, index })) + .filter(({ entry, index }) => { + if (index === current) return false + if (action === 'navigateLeft') return entry.column < active.column + if (action === 'navigateRight') return entry.column > active.column + if (action === 'navigateUp') return entry.column === active.column && entry.row < active.row + if (action === 'navigateDown') return entry.column === active.column && entry.row > active.row + return false + }) + if (candidates.length === 0) return current + candidates.sort((a, b) => { + const primaryA = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(a.entry.column - active.column) + : Math.abs(a.entry.row - active.row) + const primaryB = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(b.entry.column - active.column) + : Math.abs(b.entry.row - active.row) + if (primaryA !== primaryB) return primaryA - primaryB + const secondaryA = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(a.entry.row - active.row) + : Math.abs(a.entry.column - active.column) + const secondaryB = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(b.entry.row - active.row) + : Math.abs(b.entry.column - active.column) + return secondaryA - secondaryB + }) + return candidates[0]?.index ?? current +} + +function selectGearEntry( + entries: Iwt2GearNavEntry[], + setSelectedIndex: (value: number) => void, + key: string, +) { + const index = entries.findIndex((entry) => entry.key === key) + if (index >= 0) setSelectedIndex(index) +} + +function bossName(bossId: Iwt2BossId): string { + return IWT2_BOSS_METADATA[bossId].name +} + +function infusionCostText(save: Iwt2Save, costs: Array<{ itemId: string, itemName: string, quantity: number }>): string { + return costs + .map((cost) => `${inventoryQuantity(save.inventory, cost.itemId)}/${cost.quantity} ${cost.itemName}`) + .join(' | ') +} + function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) { if (entries.length === 0) return undefined return entries[Math.min(index, entries.length - 1)] diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts index cf9fa29..3542a35 100644 --- a/src/modes/iwt2/sim/arena.ts +++ b/src/modes/iwt2/sim/arena.ts @@ -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,29 @@ 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, }) 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 @@ -862,13 +887,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 { diff --git a/src/modes/iwt2/sim/arenaState.ts b/src/modes/iwt2/sim/arenaState.ts index e05e1f5..d462958 100644 --- a/src/modes/iwt2/sim/arenaState.ts +++ b/src/modes/iwt2/sim/arenaState.ts @@ -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 { diff --git a/src/modes/iwt2/sim/bossAi.ts b/src/modes/iwt2/sim/bossAi.ts index 5f5eac1..822a39e 100644 --- a/src/modes/iwt2/sim/bossAi.ts +++ b/src/modes/iwt2/sim/bossAi.ts @@ -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 { diff --git a/src/modes/iwt2/sim/cinderbackAi.ts b/src/modes/iwt2/sim/cinderbackAi.ts index 2beb7d1..67deaf4 100644 --- a/src/modes/iwt2/sim/cinderbackAi.ts +++ b/src/modes/iwt2/sim/cinderbackAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/crystalBatMatriarchAi.ts b/src/modes/iwt2/sim/crystalBatMatriarchAi.ts index 742956a..f663b74 100644 --- a/src/modes/iwt2/sim/crystalBatMatriarchAi.ts +++ b/src/modes/iwt2/sim/crystalBatMatriarchAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/emberMantisAi.ts b/src/modes/iwt2/sim/emberMantisAi.ts index 6054973..d6db7f5 100644 --- a/src/modes/iwt2/sim/emberMantisAi.ts +++ b/src/modes/iwt2/sim/emberMantisAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/equipmentStats.ts b/src/modes/iwt2/sim/equipmentStats.ts new file mode 100644 index 0000000..bb9825f --- /dev/null +++ b/src/modes/iwt2/sim/equipmentStats.ts @@ -0,0 +1,77 @@ +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>(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 = { ...member } + 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) +} diff --git a/src/modes/iwt2/sim/healing.ts b/src/modes/iwt2/sim/healing.ts index 9512455..0918ac6 100644 --- a/src/modes/iwt2/sim/healing.ts +++ b/src/modes/iwt2/sim/healing.ts @@ -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( 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, diff --git a/src/modes/iwt2/sim/hollowcrownRevenantAi.ts b/src/modes/iwt2/sim/hollowcrownRevenantAi.ts index ee58f49..f2f23b8 100644 --- a/src/modes/iwt2/sim/hollowcrownRevenantAi.ts +++ b/src/modes/iwt2/sim/hollowcrownRevenantAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/index.ts b/src/modes/iwt2/sim/index.ts index 742ade5..5d266d8 100644 --- a/src/modes/iwt2/sim/index.ts +++ b/src/modes/iwt2/sim/index.ts @@ -27,6 +27,11 @@ export { castIwt2HealerAbility, } from './healing' +export { + applyIwt2InfusionLoadout, + selectedInfusionForMember, +} from './infusionLoadout' + export { addVec2, clamp, diff --git a/src/modes/iwt2/sim/infusionLoadout.ts b/src/modes/iwt2/sim/infusionLoadout.ts new file mode 100644 index 0000000..b4f3f10 --- /dev/null +++ b/src/modes/iwt2/sim/infusionLoadout.ts @@ -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>( + 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, + 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 +} diff --git a/src/modes/iwt2/sim/mechanics.ts b/src/modes/iwt2/sim/mechanics.ts index a7152e3..e0470ec 100644 --- a/src/modes/iwt2/sim/mechanics.ts +++ b/src/modes/iwt2/sim/mechanics.ts @@ -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( diff --git a/src/modes/iwt2/sim/obsidianRamGolemAi.ts b/src/modes/iwt2/sim/obsidianRamGolemAi.ts index e7b947b..e1cc50d 100644 --- a/src/modes/iwt2/sim/obsidianRamGolemAi.ts +++ b/src/modes/iwt2/sim/obsidianRamGolemAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/partyAi.ts b/src/modes/iwt2/sim/partyAi.ts index 4171aab..710e0ef 100644 --- a/src/modes/iwt2/sim/partyAi.ts +++ b/src/modes/iwt2/sim/partyAi.ts @@ -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( diff --git a/src/modes/iwt2/sim/rimebastionAi.ts b/src/modes/iwt2/sim/rimebastionAi.ts index 1bca418..32bf370 100644 --- a/src/modes/iwt2/sim/rimebastionAi.ts +++ b/src/modes/iwt2/sim/rimebastionAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/roguelikePressure.ts b/src/modes/iwt2/sim/roguelikePressure.ts new file mode 100644 index 0000000..57b65db --- /dev/null +++ b/src/modes/iwt2/sim/roguelikePressure.ts @@ -0,0 +1,92 @@ +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_DUNGEON_DAMAGE_BASE_SCALE = 1.25 +const ROGUELIKE_RAID_DAMAGE_BASE_SCALE = 1.45 +const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure' + +export function roguelikeIncomingDamageScale( + _stage: number, + contentType: Iwt2RoguelikePressureContentType, +): number { + const base = contentType === 'dungeon' + ? ROGUELIKE_DUNGEON_DAMAGE_BASE_SCALE + : ROGUELIKE_RAID_DAMAGE_BASE_SCALE + return base +} + +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 +} diff --git a/src/modes/iwt2/sim/sandglassScorpionAi.ts b/src/modes/iwt2/sim/sandglassScorpionAi.ts index 692b01f..738e49b 100644 --- a/src/modes/iwt2/sim/sandglassScorpionAi.ts +++ b/src/modes/iwt2/sim/sandglassScorpionAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/stormcoilWyrmAi.ts b/src/modes/iwt2/sim/stormcoilWyrmAi.ts index 63ba0f1..1ff9032 100644 --- a/src/modes/iwt2/sim/stormcoilWyrmAi.ts +++ b/src/modes/iwt2/sim/stormcoilWyrmAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/types.ts b/src/modes/iwt2/sim/types.ts index e5ba46b..145ce6e 100644 --- a/src/modes/iwt2/sim/types.ts +++ b/src/modes/iwt2/sim/types.ts @@ -1,5 +1,6 @@ import type { Iwt2BossId } from '../content/bosses' import type { Iwt2PlayerClassId } from '../content/classes' +import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities' export type Iwt2Vec2 = { x: number @@ -47,13 +48,24 @@ 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 @@ -270,6 +282,7 @@ export type Iwt2GroundHazardState = { export type Iwt2ArenaEventType = | 'bossDamaged' + | 'healerSpellCast' | 'partyHealed' | 'partyDamaged' | 'partyStunned' @@ -287,6 +300,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 +377,7 @@ export type Iwt2ArenaState = { boss: Iwt2BossEntityState bosses: Iwt2BossEntityState[] indicators: Iwt2ArenaIndicator[] + roguelikePressure?: Iwt2RoguelikePressureState nextEventId: number nextProjectileId: number nextAddId: number diff --git a/src/modes/iwt2/sim/venomOrchidHydraAi.ts b/src/modes/iwt2/sim/venomOrchidHydraAi.ts index fdd7e67..ec93161 100644 --- a/src/modes/iwt2/sim/venomOrchidHydraAi.ts +++ b/src/modes/iwt2/sim/venomOrchidHydraAi.ts @@ -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, diff --git a/src/modes/iwt2/sim/yianKutKuAi.ts b/src/modes/iwt2/sim/yianKutKuAi.ts index 5b97167..3c131ea 100644 --- a/src/modes/iwt2/sim/yianKutKuAi.ts +++ b/src/modes/iwt2/sim/yianKutKuAi.ts @@ -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 diff --git a/src/shared/rewardRules.mjs b/src/shared/rewardRules.mjs index 54b6c64..310d32f 100644 --- a/src/shared/rewardRules.mjs +++ b/src/shared/rewardRules.mjs @@ -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) } diff --git a/tmp/imagegen/mage-fireball-alpha.png b/tmp/imagegen/mage-fireball-alpha.png new file mode 100644 index 0000000..d0d9237 Binary files /dev/null and b/tmp/imagegen/mage-fireball-alpha.png differ diff --git a/tmp/imagegen/mage-fireball-source.png b/tmp/imagegen/mage-fireball-source.png new file mode 100644 index 0000000..74ef363 Binary files /dev/null and b/tmp/imagegen/mage-fireball-source.png differ diff --git a/tmp/imagegen/ranger-arrow-alpha.png b/tmp/imagegen/ranger-arrow-alpha.png new file mode 100644 index 0000000..1e9ff8f Binary files /dev/null and b/tmp/imagegen/ranger-arrow-alpha.png differ diff --git a/tmp/imagegen/ranger-arrow-source.png b/tmp/imagegen/ranger-arrow-source.png new file mode 100644 index 0000000..22ac26f Binary files /dev/null and b/tmp/imagegen/ranger-arrow-source.png differ diff --git a/tmp/iwt2-class-art/class-art-contact-sheet.png b/tmp/iwt2-class-art/class-art-contact-sheet.png new file mode 100644 index 0000000..eca0d0d Binary files /dev/null and b/tmp/iwt2-class-art/class-art-contact-sheet.png differ diff --git a/tmp/iwt2-class-art/final/healer-arena-alpha.png b/tmp/iwt2-class-art/final/healer-arena-alpha.png new file mode 100644 index 0000000..33ecb74 Binary files /dev/null and b/tmp/iwt2-class-art/final/healer-arena-alpha.png differ diff --git a/tmp/iwt2-class-art/final/healer-ui-alpha.png b/tmp/iwt2-class-art/final/healer-ui-alpha.png new file mode 100644 index 0000000..83c21bb Binary files /dev/null and b/tmp/iwt2-class-art/final/healer-ui-alpha.png differ diff --git a/tmp/iwt2-class-art/final/mage-arena-alpha.png b/tmp/iwt2-class-art/final/mage-arena-alpha.png new file mode 100644 index 0000000..d2c4420 Binary files /dev/null and b/tmp/iwt2-class-art/final/mage-arena-alpha.png differ diff --git a/tmp/iwt2-class-art/final/mage-ui-alpha.png b/tmp/iwt2-class-art/final/mage-ui-alpha.png new file mode 100644 index 0000000..9538b41 Binary files /dev/null and b/tmp/iwt2-class-art/final/mage-ui-alpha.png differ diff --git a/tmp/iwt2-class-art/final/paladin-arena-alpha.png b/tmp/iwt2-class-art/final/paladin-arena-alpha.png new file mode 100644 index 0000000..b05e0fc Binary files /dev/null and b/tmp/iwt2-class-art/final/paladin-arena-alpha.png differ diff --git a/tmp/iwt2-class-art/final/paladin-ui-alpha.png b/tmp/iwt2-class-art/final/paladin-ui-alpha.png new file mode 100644 index 0000000..8670980 Binary files /dev/null and b/tmp/iwt2-class-art/final/paladin-ui-alpha.png differ diff --git a/tmp/iwt2-class-art/final/ranger-arena-alpha.png b/tmp/iwt2-class-art/final/ranger-arena-alpha.png new file mode 100644 index 0000000..0837ecc Binary files /dev/null and b/tmp/iwt2-class-art/final/ranger-arena-alpha.png differ diff --git a/tmp/iwt2-class-art/final/ranger-ui-alpha.png b/tmp/iwt2-class-art/final/ranger-ui-alpha.png new file mode 100644 index 0000000..ed97672 Binary files /dev/null and b/tmp/iwt2-class-art/final/ranger-ui-alpha.png differ diff --git a/tmp/iwt2-class-art/final/rogue-arena-alpha.png b/tmp/iwt2-class-art/final/rogue-arena-alpha.png new file mode 100644 index 0000000..0083caa Binary files /dev/null and b/tmp/iwt2-class-art/final/rogue-arena-alpha.png differ diff --git a/tmp/iwt2-class-art/final/rogue-ui-alpha.png b/tmp/iwt2-class-art/final/rogue-ui-alpha.png new file mode 100644 index 0000000..99b23e8 Binary files /dev/null and b/tmp/iwt2-class-art/final/rogue-ui-alpha.png differ diff --git a/tmp/iwt2-class-art/final/warrior-arena-alpha.png b/tmp/iwt2-class-art/final/warrior-arena-alpha.png new file mode 100644 index 0000000..8b40b5f Binary files /dev/null and b/tmp/iwt2-class-art/final/warrior-arena-alpha.png differ diff --git a/tmp/iwt2-class-art/final/warrior-ui-alpha.png b/tmp/iwt2-class-art/final/warrior-ui-alpha.png new file mode 100644 index 0000000..3ab90ce Binary files /dev/null and b/tmp/iwt2-class-art/final/warrior-ui-alpha.png differ diff --git a/tmp/iwt2-class-art/raw/healer-arena-source.png b/tmp/iwt2-class-art/raw/healer-arena-source.png new file mode 100644 index 0000000..0ca9bf2 Binary files /dev/null and b/tmp/iwt2-class-art/raw/healer-arena-source.png differ diff --git a/tmp/iwt2-class-art/raw/healer-ui-source.png b/tmp/iwt2-class-art/raw/healer-ui-source.png new file mode 100644 index 0000000..4fbd8b4 Binary files /dev/null and b/tmp/iwt2-class-art/raw/healer-ui-source.png differ diff --git a/tmp/iwt2-class-art/raw/mage-arena-source.png b/tmp/iwt2-class-art/raw/mage-arena-source.png new file mode 100644 index 0000000..5aeedf3 Binary files /dev/null and b/tmp/iwt2-class-art/raw/mage-arena-source.png differ diff --git a/tmp/iwt2-class-art/raw/mage-ui-source.png b/tmp/iwt2-class-art/raw/mage-ui-source.png new file mode 100644 index 0000000..cd21433 Binary files /dev/null and b/tmp/iwt2-class-art/raw/mage-ui-source.png differ diff --git a/tmp/iwt2-class-art/raw/paladin-arena-source.png b/tmp/iwt2-class-art/raw/paladin-arena-source.png new file mode 100644 index 0000000..bf99cd7 Binary files /dev/null and b/tmp/iwt2-class-art/raw/paladin-arena-source.png differ diff --git a/tmp/iwt2-class-art/raw/paladin-ui-source.png b/tmp/iwt2-class-art/raw/paladin-ui-source.png new file mode 100644 index 0000000..229ba77 Binary files /dev/null and b/tmp/iwt2-class-art/raw/paladin-ui-source.png differ diff --git a/tmp/iwt2-class-art/raw/ranger-arena-source.png b/tmp/iwt2-class-art/raw/ranger-arena-source.png new file mode 100644 index 0000000..9da08e8 Binary files /dev/null and b/tmp/iwt2-class-art/raw/ranger-arena-source.png differ diff --git a/tmp/iwt2-class-art/raw/ranger-ui-source.png b/tmp/iwt2-class-art/raw/ranger-ui-source.png new file mode 100644 index 0000000..e6900b2 Binary files /dev/null and b/tmp/iwt2-class-art/raw/ranger-ui-source.png differ diff --git a/tmp/iwt2-class-art/raw/rogue-arena-source.png b/tmp/iwt2-class-art/raw/rogue-arena-source.png new file mode 100644 index 0000000..2925699 Binary files /dev/null and b/tmp/iwt2-class-art/raw/rogue-arena-source.png differ diff --git a/tmp/iwt2-class-art/raw/rogue-ui-source.png b/tmp/iwt2-class-art/raw/rogue-ui-source.png new file mode 100644 index 0000000..450cabe Binary files /dev/null and b/tmp/iwt2-class-art/raw/rogue-ui-source.png differ diff --git a/tmp/iwt2-class-art/raw/warrior-arena-source.png b/tmp/iwt2-class-art/raw/warrior-arena-source.png new file mode 100644 index 0000000..d637155 Binary files /dev/null and b/tmp/iwt2-class-art/raw/warrior-arena-source.png differ diff --git a/tmp/iwt2-class-art/raw/warrior-ui-source.png b/tmp/iwt2-class-art/raw/warrior-ui-source.png new file mode 100644 index 0000000..6835139 Binary files /dev/null and b/tmp/iwt2-class-art/raw/warrior-ui-source.png differ