Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70f916f31 | ||
|
|
9708ba9e40 |
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 101
|
versionCode 103
|
||||||
versionName "1.1.22"
|
versionName "1.1.24"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||||
ip_address TEXT PRIMARY KEY,
|
ip_address TEXT PRIMARY KEY,
|
||||||
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
|
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Barroth boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Mirehorn boss sprite">
|
||||||
<path d="M20 80c6-31 35-49 68-42 31 6 48 27 43 52-5 26-34 41-68 34-30-6-48-24-43-44Z" fill="#8d714d"/>
|
<path d="M20 80c6-31 35-49 68-42 31 6 48 27 43 52-5 26-34 41-68 34-30-6-48-24-43-44Z" fill="#8d714d"/>
|
||||||
<path d="M44 49c16-15 49-17 72-1-28 0-53 6-76 20Z" fill="#d6bd80"/>
|
<path d="M44 49c16-15 49-17 72-1-28 0-53 6-76 20Z" fill="#d6bd80"/>
|
||||||
<path d="M56 29h48l17 24-23 17H55L35 52Z" fill="#6a5137"/>
|
<path d="M56 29h48l17 24-23 17H55L35 52Z" fill="#6a5137"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 765 B After Width: | Height: | Size: 766 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Bulldrome boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Bristlemaw boss sprite">
|
||||||
<path d="M22 80c4-31 32-50 64-43 26 6 42 23 43 45 2 23-16 39-43 41-34 2-62-14-64-43Z" fill="#7a4b35"/>
|
<path d="M22 80c4-31 32-50 64-43 26 6 42 23 43 45 2 23-16 39-43 41-34 2-62-14-64-43Z" fill="#7a4b35"/>
|
||||||
<path d="M42 91c11 15 42 22 67 10-9 16-28 24-52 18-13-4-23-13-28-24l13-4Z" fill="#3b2d28" opacity=".55"/>
|
<path d="M42 91c11 15 42 22 67 10-9 16-28 24-52 18-13-4-23-13-28-24l13-4Z" fill="#3b2d28" opacity=".55"/>
|
||||||
<path d="M42 51c11-12 31-17 52-12 14 3 25 10 31 21-25-9-53-11-83-9Z" fill="#b48762"/>
|
<path d="M42 51c11-12 31-17 52-12 14 3 25 10 31 21-25-9-53-11-83-9Z" fill="#b48762"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 982 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Great Jaggi boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Packfang Alpha boss sprite">
|
||||||
<path d="M21 78c8-28 38-42 69-32 24 8 36 27 27 49-9 23-38 32-66 20-22-9-35-21-30-37Z" fill="#3f8f73"/>
|
<path d="M21 78c8-28 38-42 69-32 24 8 36 27 27 49-9 23-38 32-66 20-22-9-35-21-30-37Z" fill="#3f8f73"/>
|
||||||
<path d="M54 48c15-15 38-14 55 1-21 0-39 6-54 19Z" fill="#b8f0aa"/>
|
<path d="M54 48c15-15 38-14 55 1-21 0-39 6-54 19Z" fill="#b8f0aa"/>
|
||||||
<path d="M28 79 7 58l33 5Z" fill="#2a6e58"/>
|
<path d="M28 79 7 58l33 5Z" fill="#2a6e58"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 825 B After Width: | Height: | Size: 828 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Khezu boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Palevolt Maw boss sprite">
|
||||||
<path d="M27 82c2-28 27-43 55-40 25 3 45 20 45 44 0 23-21 39-50 39-30 0-52-18-50-43Z" fill="#d8d7c9"/>
|
<path d="M27 82c2-28 27-43 55-40 25 3 45 20 45 44 0 23-21 39-50 39-30 0-52-18-50-43Z" fill="#d8d7c9"/>
|
||||||
<path d="M42 55c14-13 47-13 66 0-23-2-45 2-68 16Z" fill="#fff4df"/>
|
<path d="M42 55c14-13 47-13 66 0-23-2-45 2-68 16Z" fill="#fff4df"/>
|
||||||
<path d="M37 70 10 55l15 40 30 2Z" fill="#b9b7aa"/>
|
<path d="M37 70 10 55l15 40 30 2Z" fill="#b9b7aa"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 839 B After Width: | Height: | Size: 846 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Rathian boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Verdant Wyrm boss sprite">
|
||||||
<path d="M26 77c6-30 35-48 66-40 27 7 42 30 36 55-6 24-34 38-64 29-26-8-43-25-38-44Z" fill="#4d9a58"/>
|
<path d="M26 77c6-30 35-48 66-40 27 7 42 30 36 55-6 24-34 38-64 29-26-8-43-25-38-44Z" fill="#4d9a58"/>
|
||||||
<path d="M48 51c13-17 40-22 64-4-25 0-44 6-59 20Z" fill="#f0c24d"/>
|
<path d="M48 51c13-17 40-22 64-4-25 0-44 6-59 20Z" fill="#f0c24d"/>
|
||||||
<path d="M35 69 5 38l13 54 36 10Z" fill="#3f7c4a"/>
|
<path d="M35 69 5 38l13 54 36 10Z" fill="#3f7c4a"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 844 B After Width: | Height: | Size: 849 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Tobi Kadachi boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Stormtail boss sprite">
|
||||||
<path d="M25 79c7-27 34-42 65-34 24 6 38 23 35 45-3 23-28 37-58 30-27-6-47-24-42-41Z" fill="#6a79bd"/>
|
<path d="M25 79c7-27 34-42 65-34 24 6 38 23 35 45-3 23-28 37-58 30-27-6-47-24-42-41Z" fill="#6a79bd"/>
|
||||||
<path d="M49 50c13-14 38-18 61-3-21 1-40 8-56 22Z" fill="#a8f3ff"/>
|
<path d="M49 50c13-14 38-18 61-3-21 1-40 8-56 22Z" fill="#a8f3ff"/>
|
||||||
<path d="M40 69 8 49l20 46 34 5Z" fill="#515d9d"/>
|
<path d="M40 69 8 49l20 46 34 5Z" fill="#515d9d"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 877 B After Width: | Height: | Size: 874 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Yian Kut Ku boss sprite">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Emberquill boss sprite">
|
||||||
<path d="M28 84c3-29 28-45 56-38 24 6 39 24 35 48-3 22-24 34-51 31-25-3-42-18-40-41Z" fill="#d66a35"/>
|
<path d="M28 84c3-29 28-45 56-38 24 6 39 24 35 48-3 22-24 34-51 31-25-3-42-18-40-41Z" fill="#d66a35"/>
|
||||||
<path d="M52 51c9-14 29-18 49-5-16 0-31 5-43 16Z" fill="#ffd166"/>
|
<path d="M52 51c9-14 29-18 49-5-16 0-31 5-43 16Z" fill="#ffd166"/>
|
||||||
<path d="M38 67 9 45l12 45 29 5Z" fill="#f28a3e"/>
|
<path d="M38 67 9 45l12 45 29 5Z" fill="#f28a3e"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 895 B |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -51,6 +51,14 @@ function ensureRuntimeMigrations(database) {
|
|||||||
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||||
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||||
`).run()
|
`).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) {
|
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) {
|
function itemById(database, itemId) {
|
||||||
return database.prepare(`
|
return database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1544,11 +1592,6 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
|||||||
`).get(encounterId, difficultyId)
|
`).get(encounterId, difficultyId)
|
||||||
if (!context) throw new Error('That loot table is not available.')
|
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(`
|
const entries = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
encounter_loot.drop_weight AS dropWeight,
|
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
|
JOIN classes ON classes.id = characters.class_id
|
||||||
WHERE characters.id = ?
|
WHERE characters.id = ?
|
||||||
`).get(characterId)
|
`).get(characterId)
|
||||||
if (character.level < dungeon.unlockLevel) {
|
|
||||||
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
const maxLevel = Number(
|
const maxLevel = Number(
|
||||||
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
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
|
FROM characters
|
||||||
WHERE characters.id = ?
|
WHERE characters.id = ?
|
||||||
`).get(characterId)
|
`).get(characterId)
|
||||||
if (character.level < dungeon.unlockLevel) {
|
|
||||||
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
|
|
||||||
}
|
|
||||||
const maxLevel = Number(
|
const maxLevel = Number(
|
||||||
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
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
|
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') {
|
if (request.url === '/api/profile' && request.method === 'GET') {
|
||||||
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -111,6 +111,106 @@
|
|||||||
margin: 0;
|
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 {
|
.iwt2-arena-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -259,7 +359,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: 24px minmax(0, 1fr);
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
height: 86px;
|
height: 86px;
|
||||||
min-height: 86px;
|
min-height: 86px;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
@@ -269,27 +369,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row.has-target-binding {
|
.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;
|
align-items: center;
|
||||||
border: 2px solid #050608;
|
border: 2px solid #050608;
|
||||||
color: #fff7df;
|
color: #fff7df;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
justify-content: center;
|
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 {
|
.iwt2-party-row .iwt2-party-target-key {
|
||||||
|
align-items: center;
|
||||||
background: #050608;
|
background: #050608;
|
||||||
border-color: #0b0d12;
|
border-color: #0b0d12;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
font-family: var(--pixel-font);
|
font-family: var(--pixel-font);
|
||||||
font-size: 0.52rem;
|
font-size: 0.52rem;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
justify-content: center;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -591,6 +702,55 @@
|
|||||||
padding: 0 14px;
|
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 {
|
.iwt2-result-button.is-primary {
|
||||||
background: #263448;
|
background: #263448;
|
||||||
color: #fff4a8;
|
color: #fff4a8;
|
||||||
@@ -658,6 +818,68 @@
|
|||||||
font-size: 0.75rem;
|
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-action-list,
|
||||||
.iwt2-info-list {
|
.iwt2-info-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -725,6 +947,98 @@
|
|||||||
outline-color: #e5b95f;
|
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 {
|
.iwt2-dungeon-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -740,6 +1054,206 @@
|
|||||||
padding: 10px 12px;
|
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 {
|
.iwt2-controller-preview {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
background: #141922;
|
background: #141922;
|
||||||
@@ -768,11 +1282,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-bottom-display .dual-control-chip {
|
.iwt2-bottom-display .dual-control-chip {
|
||||||
|
align-items: center;
|
||||||
background: var(--panel-light);
|
background: var(--panel-light);
|
||||||
border: 2px solid #090a0d;
|
border: 2px solid #090a0d;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
outline: 2px solid #494756;
|
outline: 2px solid #494756;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bottom-target-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.game-version-grid,
|
.game-version-grid,
|
||||||
@@ -871,6 +1395,7 @@
|
|||||||
|
|
||||||
.iwt2-shell .character-summary {
|
.iwt2-shell .character-summary {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-shell .character-summary strong {
|
.iwt2-shell .character-summary strong {
|
||||||
@@ -881,6 +1406,16 @@
|
|||||||
font-size: 6px;
|
font-size: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-header-title {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-header-back {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-menu-screen {
|
.iwt2-menu-screen {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -939,6 +1474,11 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-screen {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-screen-shell .screen-heading {
|
.iwt2-screen-shell .screen-heading {
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -958,6 +1498,39 @@
|
|||||||
padding: 5px 9px;
|
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-action-list,
|
||||||
.iwt2-info-list {
|
.iwt2-info-list {
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -979,8 +1552,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-dungeon-list .iwt2-action-row {
|
.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;
|
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,
|
.iwt2-action-row strong,
|
||||||
@@ -1060,21 +1711,21 @@
|
|||||||
|
|
||||||
.iwt2-party-row {
|
.iwt2-party-row {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
grid-template-columns: 20px minmax(0, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
height: 62px;
|
height: 62px;
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row.has-target-binding {
|
.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 {
|
.iwt2-party-row .iwt2-party-target-key {
|
||||||
height: 20px;
|
height: 22px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
width: 20px;
|
width: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
||||||
@@ -11980,6 +12631,17 @@ h2 {
|
|||||||
flex: 1 1 auto;
|
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-action-row:not(:has(small)),
|
||||||
.iwt2-info-row:not(:has(small)) {
|
.iwt2-info-row:not(:has(small)) {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
|
|||||||
@@ -116,7 +116,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedVersion === 'iwt2') {
|
if (selectedVersion === 'iwt2') {
|
||||||
return <IWantToHeal2App onBackToGameSelect={() => setSelectedVersion(null)} />
|
return (
|
||||||
|
<IWantToHeal2App
|
||||||
|
onlineBackupsAvailable={authSession.account?.id !== -1}
|
||||||
|
onBackToGameSelect={() => setSelectedVersion(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ export type RewardSummaryBase = {
|
|||||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RunBossCoinAward = NonNullable<DungeonReward['bonusItem']> & {
|
||||||
|
sourceLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
export type PvpRunRewardSummary = RewardSummaryBase & {
|
export type PvpRunRewardSummary = RewardSummaryBase & {
|
||||||
bossesKilled: number
|
bossesKilled: number
|
||||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
loot: RunBossCoinAward[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyRewardSummary(): RewardSummaryBase {
|
export function createEmptyRewardSummary(): RewardSummaryBase {
|
||||||
@@ -60,11 +64,13 @@ export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
|
|||||||
export function mergePvpRunRewardSummary(
|
export function mergePvpRunRewardSummary(
|
||||||
current: PvpRunRewardSummary,
|
current: PvpRunRewardSummary,
|
||||||
reward: DungeonReward,
|
reward: DungeonReward,
|
||||||
{ bossKilled }: { bossKilled: boolean },
|
{ bossKilled, sourceLabel }: { bossKilled: boolean; sourceLabel?: string },
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...mergeDungeonRewardSummary(current, reward),
|
...mergeDungeonRewardSummary(current, reward),
|
||||||
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
completeDungeon,
|
completeDungeon,
|
||||||
completeRoguelike,
|
completeRoguelike,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
recordBossKill,
|
|
||||||
type DungeonReward,
|
type DungeonReward,
|
||||||
rollEncounterLoot,
|
rollEncounterLoot,
|
||||||
type LootRoll,
|
type LootRoll,
|
||||||
@@ -55,6 +54,7 @@ import {
|
|||||||
createPveCombatState,
|
createPveCombatState,
|
||||||
createPveRoguelikeRunStart,
|
createPveRoguelikeRunStart,
|
||||||
} from '../combat/pveRoguelikeRunSetup'
|
} from '../combat/pveRoguelikeRunSetup'
|
||||||
|
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||||
import {
|
import {
|
||||||
appendCombatLog,
|
appendCombatLog,
|
||||||
type BasicFloatingCombatText,
|
type BasicFloatingCombatText,
|
||||||
@@ -79,7 +79,7 @@ import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
|||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { ResourceBar, SpellBar } from './SpellBars'
|
import { ResourceBar, SpellBar } from './SpellBars'
|
||||||
import { barFillStyle } from './barStyles'
|
import { barFillStyle } from './barStyles'
|
||||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||||
import { ResultScreen } from './ResultScreen'
|
import { ResultScreen } from './ResultScreen'
|
||||||
import {
|
import {
|
||||||
DualScreenTopCombat,
|
DualScreenTopCombat,
|
||||||
@@ -236,6 +236,7 @@ function makeRoguelikeSegment(
|
|||||||
mode: RoguelikeMode,
|
mode: RoguelikeMode,
|
||||||
): RoguelikeEncounter[] {
|
): RoguelikeEncounter[] {
|
||||||
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||||
|
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
|
||||||
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
||||||
pool,
|
pool,
|
||||||
stage,
|
stage,
|
||||||
@@ -243,8 +244,8 @@ function makeRoguelikeSegment(
|
|||||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
||||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 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)),
|
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
|
||||||
damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)),
|
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
|
||||||
partyDamageScale: 0.9 + stage * 0.05,
|
partyDamageScale: 0.95,
|
||||||
idBase: 900000,
|
idBase: 900000,
|
||||||
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
||||||
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
||||||
@@ -359,6 +360,7 @@ export function CombatScreen({
|
|||||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||||
const [rewardError, setRewardError] = useState('')
|
const [rewardError, setRewardError] = useState('')
|
||||||
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
||||||
|
const [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
|
||||||
const [showEndLog, setShowEndLog] = useState(false)
|
const [showEndLog, setShowEndLog] = useState(false)
|
||||||
const {
|
const {
|
||||||
floatingTexts,
|
floatingTexts,
|
||||||
@@ -540,9 +542,31 @@ export function CombatScreen({
|
|||||||
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
||||||
if (recordedBossKillIdsRef.current.has(key)) return
|
if (recordedBossKillIdsRef.current.has(key)) return
|
||||||
recordedBossKillIdsRef.current.add(key)
|
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) => {
|
.then((result) => {
|
||||||
onProfileUpdated(result.profile)
|
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) {
|
if (result.petAwarded) {
|
||||||
addLog(
|
addLog(
|
||||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||||
@@ -552,11 +576,11 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
addLog(
|
addLog(
|
||||||
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
|
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
|
||||||
'danger',
|
'danger',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
|
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
|
||||||
|
|
||||||
const resetRun = useCallback(() => {
|
const resetRun = useCallback(() => {
|
||||||
const nextRoguelikeEncounters = roguelikeMode
|
const nextRoguelikeEncounters = roguelikeMode
|
||||||
@@ -581,6 +605,7 @@ export function CombatScreen({
|
|||||||
setReward(setup.defaults.reward)
|
setReward(setup.defaults.reward)
|
||||||
setRewardError(setup.defaults.rewardError)
|
setRewardError(setup.defaults.rewardError)
|
||||||
setLootRolls(setup.defaults.lootRolls)
|
setLootRolls(setup.defaults.lootRolls)
|
||||||
|
setRoguelikeBossCoins([])
|
||||||
setShowEndLog(setup.defaults.showEndLog)
|
setShowEndLog(setup.defaults.showEndLog)
|
||||||
clearFloatingTexts()
|
clearFloatingTexts()
|
||||||
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
||||||
@@ -1434,7 +1459,11 @@ export function CombatScreen({
|
|||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
{reward && (
|
{reward && (
|
||||||
<>
|
<>
|
||||||
|
{isRoguelike && <p>Run totals</p>}
|
||||||
<RewardXpSummary reward={reward} />
|
<RewardXpSummary reward={reward} />
|
||||||
|
{isRoguelike && (
|
||||||
|
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||||
|
)}
|
||||||
{!isRoguelike && (
|
{!isRoguelike && (
|
||||||
<>
|
<>
|
||||||
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
||||||
@@ -1459,6 +1488,7 @@ export function CombatScreen({
|
|||||||
<>
|
<>
|
||||||
<RewardXpSummary reward={reward} />
|
<RewardXpSummary reward={reward} />
|
||||||
<p>{encounterIndex} encounters cleared.</p>
|
<p>{encounterIndex} encounters cleared.</p>
|
||||||
|
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||||
<p className="efficiency-result">
|
<p className="efficiency-result">
|
||||||
{reward.resourceSpent} {gameClass.resourceName} spent
|
{reward.resourceSpent} {gameClass.resourceName} spent
|
||||||
<small>{reward.durationSeconds}s survived</small>
|
<small>{reward.durationSeconds}s survived</small>
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs
|
|||||||
|
|
||||||
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
||||||
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||||
|
const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
|
||||||
return buildRoguelikeSegment<BossMechanic, {
|
return buildRoguelikeSegment<BossMechanic, {
|
||||||
bossMechanics: BossMechanic[]
|
bossMechanics: BossMechanic[]
|
||||||
sourceEncounterId?: number
|
sourceEncounterId?: number
|
||||||
@@ -240,8 +241,8 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
|||||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
|
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
|
||||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
||||||
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
|
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
|
||||||
damageScale: 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14),
|
damageScale: stageOneDamageScale,
|
||||||
partyDamageScale: 0.85 + stage * 0.04,
|
partyDamageScale: 0.89,
|
||||||
idBase: 910000,
|
idBase: 910000,
|
||||||
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
||||||
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
||||||
@@ -537,7 +538,10 @@ export function PvPRoguelikeScreen({
|
|||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: isBossReward }))
|
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||||
|
bossKilled: isBossReward,
|
||||||
|
sourceLabel: rewardEncounter?.enemyName,
|
||||||
|
}))
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
if (result.experienceGained > 0) {
|
if (result.experienceGained > 0) {
|
||||||
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||||
@@ -579,7 +583,10 @@ export function PvPRoguelikeScreen({
|
|||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: false }))
|
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||||
|
bossKilled: false,
|
||||||
|
sourceLabel: 'Match Win',
|
||||||
|
}))
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
if (result.experienceGained > 0) {
|
if (result.experienceGained > 0) {
|
||||||
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
||||||
@@ -1808,11 +1815,12 @@ export function PvPRoguelikeScreen({
|
|||||||
>
|
>
|
||||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||||
<div className="reward-summary">
|
<div className="reward-summary">
|
||||||
|
<p>Run totals</p>
|
||||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||||
<RewardXpSummary reward={runSummary} />
|
<RewardXpSummary reward={runSummary} />
|
||||||
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
<PvpRunLootList loot={runSummary.loot} />
|
<PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
|
||||||
{reward && runSummary.bossesKilled === 0 && (
|
{reward && runSummary.bossesKilled === 0 && (
|
||||||
<>
|
<>
|
||||||
<RewardXpSummary reward={reward} />
|
<RewardXpSummary reward={reward} />
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
type Spell,
|
type Spell,
|
||||||
} from '../game'
|
} from '../game'
|
||||||
import { completeRoguelike, recordPvpMatch } from '../profile'
|
import { completeRoguelike, recordPvpMatch } from '../profile'
|
||||||
import type { CharacterProfile } from '../profile'
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
|
import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs'
|
||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { SpellBar } from './SpellBars'
|
import { SpellBar } from './SpellBars'
|
||||||
import { barFillStyle } from './barStyles'
|
import { barFillStyle } from './barStyles'
|
||||||
@@ -64,9 +65,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
createEmptyRewardSummary,
|
createEmptyRewardSummary,
|
||||||
mergeDungeonRewardSummary,
|
mergeDungeonRewardSummary,
|
||||||
|
type RunBossCoinAward,
|
||||||
type RewardSummaryBase,
|
type RewardSummaryBase,
|
||||||
} from '../combat/rewardSummaries'
|
} from '../combat/rewardSummaries'
|
||||||
import { RewardXpSummary } from './RewardPanels'
|
import { PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||||
import { ResultScreen } from './ResultScreen'
|
import { ResultScreen } from './ResultScreen'
|
||||||
import {
|
import {
|
||||||
publishPvpMatchState,
|
publishPvpMatchState,
|
||||||
@@ -158,6 +160,25 @@ function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
|
|||||||
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
|
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[] {
|
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
|
||||||
const directName = slotSpellName('1', spells, 'Mend')
|
const directName = slotSpellName('1', spells, 'Mend')
|
||||||
const sustainName = slotSpellName('2', spells, 'Renew')
|
const sustainName = slotSpellName('2', spells, 'Renew')
|
||||||
@@ -330,11 +351,11 @@ export function PvpStadiumScreen({
|
|||||||
onProfileUpdated: (profile: CharacterProfile) => void
|
onProfileUpdated: (profile: CharacterProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||||
const starterSpells = useMemo(() => gameClass.spells
|
const [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||||
.filter((spell) => spell.unlockLevel === 1)
|
const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass])
|
||||||
.slice(0, 5)
|
const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass])
|
||||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
|
||||||
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
||||||
|
const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells])
|
||||||
const partyTemplate = useMemo(
|
const partyTemplate = useMemo(
|
||||||
() => INITIAL_PARTY.map((member) => ({
|
() => INITIAL_PARTY.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
@@ -351,6 +372,12 @@ export function PvpStadiumScreen({
|
|||||||
)
|
)
|
||||||
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
||||||
const rewardDifficulty = rewardDungeon.difficulties[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 [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
|
||||||
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
||||||
partyTemplate,
|
partyTemplate,
|
||||||
@@ -382,6 +409,7 @@ export function PvpStadiumScreen({
|
|||||||
clearFloatingTexts,
|
clearFloatingTexts,
|
||||||
} = useSidedFloatingCombatText()
|
} = useSidedFloatingCombatText()
|
||||||
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
||||||
|
const [stadiumBossCoins, setStadiumBossCoins] = useState<RunBossCoinAward[]>([])
|
||||||
const [rewardError, setRewardError] = useState('')
|
const [rewardError, setRewardError] = useState('')
|
||||||
const [showEndLog, setShowEndLog] = useState(false)
|
const [showEndLog, setShowEndLog] = useState(false)
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
@@ -417,8 +445,8 @@ export function PvpStadiumScreen({
|
|||||||
cost: playerSpellSlotCost,
|
cost: playerSpellSlotCost,
|
||||||
})
|
})
|
||||||
const opponentBuffSummary = useMemo(
|
const opponentBuffSummary = useMemo(
|
||||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
() => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog),
|
||||||
[buffCatalog, cpuSide.buffs],
|
[buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch],
|
||||||
)
|
)
|
||||||
const playerBuffSummary = useMemo(
|
const playerBuffSummary = useMemo(
|
||||||
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
||||||
@@ -473,18 +501,31 @@ export function PvpStadiumScreen({
|
|||||||
setStatus('round-countdown')
|
setStatus('round-countdown')
|
||||||
}, [clearRoundCountdown, startRoundCountdown])
|
}, [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
|
if (awardedXpRef.current.has(key)) return
|
||||||
awardedXpRef.current.add(key)
|
awardedXpRef.current.add(key)
|
||||||
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
|
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
|
||||||
bossesCleared: 0,
|
bossesCleared: 0,
|
||||||
fightsCleared: 1,
|
fightsCleared: 1,
|
||||||
experienceMode: mode,
|
experienceMode: mode,
|
||||||
|
lootSourceEncounterId: coinSource?.encounter.id,
|
||||||
|
roguelikeStage: coinSource?.stage,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
||||||
|
if (result.bonusItem && coinSource) {
|
||||||
|
setStadiumBossCoins((current) => [...current, {
|
||||||
|
...result.bonusItem!,
|
||||||
|
sourceLabel: coinSource.label,
|
||||||
|
}])
|
||||||
|
}
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
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) => {
|
.catch((reason: unknown) => {
|
||||||
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
|
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
|
||||||
@@ -521,6 +562,7 @@ export function PvpStadiumScreen({
|
|||||||
setLiveMatch(setup.liveMatch)
|
setLiveMatch(setup.liveMatch)
|
||||||
setPaused(setup.defaults.paused)
|
setPaused(setup.defaults.paused)
|
||||||
setRewardSummary(createEmptyRewardSummary())
|
setRewardSummary(createEmptyRewardSummary())
|
||||||
|
setStadiumBossCoins([])
|
||||||
setRewardError(setup.defaults.rewardError)
|
setRewardError(setup.defaults.rewardError)
|
||||||
setShowEndLog(setup.defaults.showEndLog)
|
setShowEndLog(setup.defaults.showEndLog)
|
||||||
clearFloatingTexts()
|
clearFloatingTexts()
|
||||||
@@ -559,11 +601,13 @@ export function PvpStadiumScreen({
|
|||||||
setLiveMatch(null)
|
setLiveMatch(null)
|
||||||
setPaused(setup.defaults.paused)
|
setPaused(setup.defaults.paused)
|
||||||
setRewardSummary(createEmptyRewardSummary())
|
setRewardSummary(createEmptyRewardSummary())
|
||||||
|
setStadiumBossCoins([])
|
||||||
setRewardError(setup.defaults.rewardError)
|
setRewardError(setup.defaults.rewardError)
|
||||||
setShowEndLog(setup.defaults.showEndLog)
|
setShowEndLog(setup.defaults.showEndLog)
|
||||||
clearFloatingTexts()
|
clearFloatingTexts()
|
||||||
loggedOpponentRoundRef.current = ''
|
loggedOpponentRoundRef.current = ''
|
||||||
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||||
|
setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setQueueMessage(message)
|
setQueueMessage(message)
|
||||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
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(() => {
|
useEffect(() => {
|
||||||
const frame = window.requestAnimationFrame(() => startMatch())
|
const frame = window.requestAnimationFrame(() => startMatch())
|
||||||
@@ -602,6 +646,7 @@ export function PvpStadiumScreen({
|
|||||||
current: StadiumSideState,
|
current: StadiumSideState,
|
||||||
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
||||||
sideName: 'player' | 'cpu',
|
sideName: 'player' | 'cpu',
|
||||||
|
spells: Spell[],
|
||||||
spell: Spell,
|
spell: Spell,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
) => {
|
) => {
|
||||||
@@ -614,7 +659,7 @@ export function PvpStadiumScreen({
|
|||||||
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
|
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
|
||||||
})
|
})
|
||||||
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
|
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(
|
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
|
||||||
sourcePower
|
sourcePower
|
||||||
* strength
|
* strength
|
||||||
@@ -717,7 +762,7 @@ export function PvpStadiumScreen({
|
|||||||
wasReady: effectiveCost === 0 && current.freeCastReady,
|
wasReady: effectiveCost === 0 && current.freeCastReady,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [addFloatingHeal, starterSpells])
|
}, [addFloatingHeal])
|
||||||
|
|
||||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||||
if (status !== 'playing' || !playerAlive) return
|
if (status !== 'playing' || !playerAlive) return
|
||||||
@@ -726,9 +771,9 @@ export function PvpStadiumScreen({
|
|||||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||||
playerRef.current = next
|
playerRef.current = next
|
||||||
setPlayerSide(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')
|
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 getTargetParty = useCallback(() => playerRef.current.party, [])
|
||||||
const {
|
const {
|
||||||
@@ -749,7 +794,7 @@ export function PvpStadiumScreen({
|
|||||||
runCpuHealTurn({
|
runCpuHealTurn({
|
||||||
side: cpuRef.current,
|
side: cpuRef.current,
|
||||||
elapsedTicks,
|
elapsedTicks,
|
||||||
spells: starterSpells,
|
spells: cpuStarterSpells,
|
||||||
behavior,
|
behavior,
|
||||||
preferSlots: true,
|
preferSlots: true,
|
||||||
applySpell: (side, spell, targetId) => {
|
applySpell: (side, spell, targetId) => {
|
||||||
@@ -757,10 +802,10 @@ export function PvpStadiumScreen({
|
|||||||
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
||||||
cpuRef.current = next
|
cpuRef.current = next
|
||||||
setCpuSide(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) => {
|
const advanceBoss = useCallback((side: StadiumSideState) => {
|
||||||
if (side.roundStatus !== 'playing') return side
|
if (side.roundStatus !== 'playing') return side
|
||||||
@@ -823,7 +868,7 @@ export function PvpStadiumScreen({
|
|||||||
})
|
})
|
||||||
if (!liveMatchRef.current) {
|
if (!liveMatchRef.current) {
|
||||||
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
||||||
buffCatalog,
|
cpuBuffCatalog,
|
||||||
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
||||||
)
|
)
|
||||||
setCpuSide((current) => {
|
setCpuSide((current) => {
|
||||||
@@ -832,14 +877,17 @@ export function PvpStadiumScreen({
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [buffCatalog, startShopTimer])
|
}, [cpuBuffCatalog, startShopTimer])
|
||||||
|
|
||||||
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
|
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
|
||||||
if (status !== 'playing') return
|
if (status !== 'playing') return
|
||||||
if (roundResolvedRef.current) return
|
if (roundResolvedRef.current) return
|
||||||
roundResolvedRef.current = true
|
roundResolvedRef.current = true
|
||||||
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
|
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)
|
addLog(result.log.text, result.log.tone)
|
||||||
if (result.status === 'won') {
|
if (result.status === 'won') {
|
||||||
setRoundWins(result.nextWins)
|
setRoundWins(result.nextWins)
|
||||||
@@ -849,7 +897,13 @@ export function PvpStadiumScreen({
|
|||||||
playerRef.current = next
|
playerRef.current = next
|
||||||
return 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
|
return
|
||||||
}
|
}
|
||||||
if (result.status === 'lost') {
|
if (result.status === 'lost') {
|
||||||
@@ -863,7 +917,7 @@ export function PvpStadiumScreen({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
beginShop(outcome, result.nextWins)
|
beginShop(outcome, result.nextWins)
|
||||||
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
|
}, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'playing' || paused) return
|
if (status !== 'playing' || paused) return
|
||||||
@@ -1077,12 +1131,12 @@ export function PvpStadiumScreen({
|
|||||||
encounterCount: 5,
|
encounterCount: 5,
|
||||||
party: playerSide.party,
|
party: playerSide.party,
|
||||||
opponentName: opponentLabel,
|
opponentName: opponentLabel,
|
||||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name),
|
||||||
opponentParty: cpuSide.party,
|
opponentParty: cpuSide.party,
|
||||||
opponentEnemyHealth: 0,
|
opponentEnemyHealth: 0,
|
||||||
opponentResource: cpuSide.resource,
|
opponentResource: cpuSide.resource,
|
||||||
opponentMaxResource: MAX_RESOURCE,
|
opponentMaxResource: MAX_RESOURCE,
|
||||||
opponentResourceName: gameClass.resourceName,
|
opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
floatingTexts: dualScreenFloatingTexts,
|
floatingTexts: dualScreenFloatingTexts,
|
||||||
@@ -1110,13 +1164,15 @@ export function PvpStadiumScreen({
|
|||||||
activeBindings,
|
activeBindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
|
cpuGameClass.name,
|
||||||
|
cpuGameClass.resourceName,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
cpuSide.resource,
|
cpuSide.resource,
|
||||||
cpuSide.survivalSeconds,
|
cpuSide.survivalSeconds,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
dualScreenFloatingTexts,
|
dualScreenFloatingTexts,
|
||||||
gameClass.resourceName,
|
gameClass.resourceName,
|
||||||
liveMatch?.opponentClassName,
|
liveMatch,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
opponentLabel,
|
opponentLabel,
|
||||||
@@ -1277,10 +1333,10 @@ export function PvpStadiumScreen({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Opponent</p>
|
<p className="eyebrow">Opponent</p>
|
||||||
<h2>{opponentLabel}</h2>
|
<h2>{opponentLabel}</h2>
|
||||||
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
<small>{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
<span>{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({
|
|||||||
>
|
>
|
||||||
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
||||||
<div className="reward-summary">
|
<div className="reward-summary">
|
||||||
|
<p>Run totals</p>
|
||||||
<RewardXpSummary reward={rewardSummary} />
|
<RewardXpSummary reward={rewardSummary} />
|
||||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||||
|
<PvpRunLootList loot={stadiumBossCoins} emptyLabel="No boss coins collected this run." />
|
||||||
</div>
|
</div>
|
||||||
</ResultScreen>
|
</ResultScreen>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CombatLogEntry } from '../game'
|
import type { CombatLogEntry } from '../game'
|
||||||
|
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||||
import type { DungeonReward, LootRoll } from '../profile'
|
import type { DungeonReward, LootRoll } from '../profile'
|
||||||
|
|
||||||
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
||||||
@@ -119,14 +120,16 @@ export function LootRollList({
|
|||||||
|
|
||||||
export function PvpRunLootList({
|
export function PvpRunLootList({
|
||||||
loot,
|
loot,
|
||||||
|
emptyLabel = 'No boss coins awarded',
|
||||||
}: {
|
}: {
|
||||||
loot: BonusItem[]
|
loot: RunBossCoinAward[]
|
||||||
|
emptyLabel?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="run-loot-rolls">
|
<div className="run-loot-rolls">
|
||||||
{loot.length > 0 ? loot.map((item, index) => (
|
{loot.length > 0 ? loot.map((item, index) => (
|
||||||
<div className="dropped" key={`${item.id}-${index}`}>
|
<div className="dropped" key={`${item.id}-${index}`}>
|
||||||
<strong>Boss {index + 1}</strong>
|
<strong>{item.sourceLabel}</strong>
|
||||||
<span>
|
<span>
|
||||||
{item.glyph} {item.name} x{item.quantity}
|
{item.glyph} {item.name} x{item.quantity}
|
||||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||||
@@ -134,8 +137,8 @@ export function PvpRunLootList({
|
|||||||
</div>
|
</div>
|
||||||
)) : (
|
)) : (
|
||||||
<div>
|
<div>
|
||||||
<strong>Loot</strong>
|
<strong>Boss Coins</strong>
|
||||||
<span>No boss loot awarded</span>
|
<span>{emptyLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1127,9 +1127,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
throw new Error('That difficulty is not available for this dungeon.')
|
throw new Error('That difficulty is not available for this dungeon.')
|
||||||
}
|
}
|
||||||
const cd = save.characters[save.activeClassId]
|
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 previousLevel = cd.level
|
||||||
const previousExperience = cd.experience
|
const previousExperience = cd.experience
|
||||||
@@ -1251,9 +1248,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
throw new Error('That difficulty is not available for this roguelike.')
|
throw new Error('That difficulty is not available for this roguelike.')
|
||||||
}
|
}
|
||||||
const cd = save.characters[save.activeClassId]
|
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 previousLevel = cd.level
|
||||||
const previousExperience = cd.experience
|
const previousExperience = cd.experience
|
||||||
|
|||||||
@@ -305,7 +305,6 @@ function IWantToHeal1App({
|
|||||||
const characterLevel = profile?.character.level ?? 0
|
const characterLevel = profile?.character.level ?? 0
|
||||||
const savedTier = tierOptions.find((candidate) => (
|
const savedTier = tierOptions.find((candidate) => (
|
||||||
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||||
&& characterLevel >= candidate.unlockLevel
|
|
||||||
))
|
))
|
||||||
if (savedTier) return savedTier
|
if (savedTier) return savedTier
|
||||||
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
|
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
|
||||||
@@ -500,7 +499,7 @@ function IWantToHeal1App({
|
|||||||
|
|
||||||
function dungeonEntries() {
|
function dungeonEntries() {
|
||||||
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||||
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
const locked = !profile || !difficulty
|
||||||
const entries: DungeonNavEntry[] = [
|
const entries: DungeonNavEntry[] = [
|
||||||
{ kind: 'back' },
|
{ kind: 'back' },
|
||||||
]
|
]
|
||||||
@@ -522,7 +521,7 @@ function IWantToHeal1App({
|
|||||||
entries.push({
|
entries.push({
|
||||||
kind: 'activity',
|
kind: 'activity',
|
||||||
index,
|
index,
|
||||||
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
|
disabled: !profile || !candidateDifficulty,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
entries.push(
|
entries.push(
|
||||||
@@ -533,7 +532,7 @@ function IWantToHeal1App({
|
|||||||
entries.push({
|
entries.push({
|
||||||
kind: 'tier',
|
kind: 'tier',
|
||||||
index,
|
index,
|
||||||
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
|
disabled: !profile || !difficultyOption,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
entries.push({ kind: 'loot' })
|
entries.push({ kind: 'loot' })
|
||||||
@@ -592,7 +591,6 @@ function IWantToHeal1App({
|
|||||||
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
||||||
) ?? candidate.difficulties[0]
|
) ?? candidate.difficulties[0]
|
||||||
: candidate.difficulties[0]
|
: candidate.difficulties[0]
|
||||||
if (profile && profile.character.level < difficulty.unlockLevel) return
|
|
||||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
else setSelectedDungeonId(candidate.id)
|
else setSelectedDungeonId(candidate.id)
|
||||||
setSelectedDifficultyId(difficulty.id)
|
setSelectedDifficultyId(difficulty.id)
|
||||||
@@ -601,7 +599,7 @@ function IWantToHeal1App({
|
|||||||
function selectTierByIndex(index: number) {
|
function selectTierByIndex(index: number) {
|
||||||
const difficulty = tierOptions[index]
|
const difficulty = tierOptions[index]
|
||||||
const activity = selectedActivityOption ?? activityOptions[0]
|
const activity = selectedActivityOption ?? activityOptions[0]
|
||||||
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
|
if (!difficulty || !activity) return
|
||||||
setActivityPage(0)
|
setActivityPage(0)
|
||||||
const nextActivity = activity.difficulties.some(
|
const nextActivity = activity.difficulties.some(
|
||||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
@@ -622,7 +620,7 @@ function IWantToHeal1App({
|
|||||||
function startSelectedRun(marathon: boolean) {
|
function startSelectedRun(marathon: boolean) {
|
||||||
const activity = selectedActivityOption ?? activityOptions[0]
|
const activity = selectedActivityOption ?? activityOptions[0]
|
||||||
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
||||||
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
|
if (!activity || !difficulty) return
|
||||||
setSelectedMarathonMode(marathon)
|
setSelectedMarathonMode(marathon)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
setSelectedDifficultyId(difficulty.id)
|
setSelectedDifficultyId(difficulty.id)
|
||||||
@@ -787,7 +785,6 @@ function IWantToHeal1App({
|
|||||||
|| !selectedActivityOption
|
|| !selectedActivityOption
|
||||||
|| !selectedDifficultyOption
|
|| !selectedDifficultyOption
|
||||||
) return null
|
) return null
|
||||||
const locked = profile.character.level < selectedDifficultyOption.unlockLevel
|
|
||||||
return {
|
return {
|
||||||
contentType: selectedActivityOption.contentType,
|
contentType: selectedActivityOption.contentType,
|
||||||
title: selectedActivityOption.name,
|
title: selectedActivityOption.name,
|
||||||
@@ -797,7 +794,7 @@ function IWantToHeal1App({
|
|||||||
difficultyName: selectedDifficultyOption.name,
|
difficultyName: selectedDifficultyOption.name,
|
||||||
itemLevel: selectedDifficultyOption.droppedItemLevel,
|
itemLevel: selectedDifficultyOption.droppedItemLevel,
|
||||||
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
|
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
|
||||||
lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined,
|
lockedReason: undefined,
|
||||||
stats: {
|
stats: {
|
||||||
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
|
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
|
||||||
damage: `${selectedDifficultyOption.damageMultiplier.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 activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
|
||||||
const activity = selectedActivityOption ?? dungeon
|
const activity = selectedActivityOption ?? dungeon
|
||||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
|
||||||
return (
|
return (
|
||||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||||
{screen !== 'hunter-profile' && (
|
{screen !== 'hunter-profile' && (
|
||||||
@@ -1271,13 +1267,11 @@ function IWantToHeal1App({
|
|||||||
const difficulty = candidate.difficulties.find(
|
const difficulty = candidate.difficulties.find(
|
||||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
) ?? candidate.difficulties[0]
|
) ?? candidate.difficulties[0]
|
||||||
const locked = profile.character.level < difficulty.unlockLevel
|
|
||||||
const selected = candidate.id === activity.id
|
const selected = candidate.id === activity.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
className={`activity-card ${selected ? 'selected' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={locked}
|
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
@@ -1310,16 +1304,13 @@ function IWantToHeal1App({
|
|||||||
<h2>Run</h2>
|
<h2>Run</h2>
|
||||||
</div>
|
</div>
|
||||||
<small>
|
<small>
|
||||||
{difficultyLocked
|
Pick a hunt or marathon.
|
||||||
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
|
|
||||||
: 'Pick a hunt or marathon.'}
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="part-picker">
|
<div className="part-picker">
|
||||||
<button
|
<button
|
||||||
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={difficultyLocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMarathonMode(false)
|
setSelectedMarathonMode(false)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
@@ -1334,7 +1325,6 @@ function IWantToHeal1App({
|
|||||||
<button
|
<button
|
||||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={difficultyLocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMarathonMode(true)
|
setSelectedMarathonMode(true)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
@@ -1355,17 +1345,15 @@ function IWantToHeal1App({
|
|||||||
<p className="eyebrow">Item Level</p>
|
<p className="eyebrow">Item Level</p>
|
||||||
<h2>Tier</h2>
|
<h2>Tier</h2>
|
||||||
</div>
|
</div>
|
||||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers are available at every level.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="tier-grid">
|
<div className="tier-grid">
|
||||||
{tierOptions.map((difficulty, index) => {
|
{tierOptions.map((difficulty, index) => {
|
||||||
const locked = profile.character.level < difficulty.unlockLevel
|
|
||||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
className={`${selected ? 'selected' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={locked}
|
|
||||||
key={difficulty.id}
|
key={difficulty.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActivityPage(0)
|
setActivityPage(0)
|
||||||
@@ -1389,7 +1377,7 @@ function IWantToHeal1App({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
<span>{difficulty.name}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Iwt2CloudSaveScreen,
|
Iwt2CloudSaveScreen,
|
||||||
Iwt2CustomizeCharacterScreen,
|
Iwt2CustomizeCharacterScreen,
|
||||||
Iwt2DungeonsScreen,
|
Iwt2DungeonsScreen,
|
||||||
|
Iwt2GearUpgradeScreen,
|
||||||
Iwt2HunterProfileScreen,
|
Iwt2HunterProfileScreen,
|
||||||
Iwt2ModeScreen,
|
Iwt2ModeScreen,
|
||||||
Iwt2RoguelikeScreen,
|
Iwt2RoguelikeScreen,
|
||||||
@@ -27,6 +28,13 @@ import {
|
|||||||
type Iwt2Save,
|
type Iwt2Save,
|
||||||
} from './save/iwt2Repository'
|
} from './save/iwt2Repository'
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
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 { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||||
import {
|
import {
|
||||||
buildIwt2OpponentDebuffChoices,
|
buildIwt2OpponentDebuffChoices,
|
||||||
@@ -48,12 +56,14 @@ type Iwt2Screen =
|
|||||||
| 'roguelike'
|
| 'roguelike'
|
||||||
| 'roguelike-arena'
|
| 'roguelike-arena'
|
||||||
| 'roguelike-upgrade'
|
| 'roguelike-upgrade'
|
||||||
|
| 'gear-upgrade'
|
||||||
| 'hunter-profile'
|
| 'hunter-profile'
|
||||||
| 'customize-character'
|
| 'customize-character'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|
||||||
const IWT2_MENU_COLUMNS = 4
|
const IWT2_MENU_COLUMNS = 2
|
||||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
|
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||||
|
|
||||||
type Iwt2RoguelikeRunState = {
|
type Iwt2RoguelikeRunState = {
|
||||||
bossIds: Iwt2BossId[]
|
bossIds: Iwt2BossId[]
|
||||||
@@ -72,12 +82,6 @@ const MENU_ITEMS: Array<{
|
|||||||
description: string
|
description: string
|
||||||
glyph: string
|
glyph: string
|
||||||
}> = [
|
}> = [
|
||||||
{
|
|
||||||
screen: 'cloud-save',
|
|
||||||
title: 'Backup Slot',
|
|
||||||
description: 'Save or restore the isolated IWT2 progress slot.',
|
|
||||||
glyph: 'C',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
screen: 'dungeons',
|
screen: 'dungeons',
|
||||||
title: 'Dungeons',
|
title: 'Dungeons',
|
||||||
@@ -102,6 +106,12 @@ const MENU_ITEMS: Array<{
|
|||||||
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||||
glyph: 'P',
|
glyph: 'P',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
screen: 'gear-upgrade',
|
||||||
|
title: 'Gear Upgrade',
|
||||||
|
description: 'Spend boss coins on class gear slots and infusion abilities.',
|
||||||
|
glyph: 'G',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
screen: 'hunter-profile',
|
screen: 'hunter-profile',
|
||||||
title: 'Hunter Profile',
|
title: 'Hunter Profile',
|
||||||
@@ -114,6 +124,12 @@ const MENU_ITEMS: Array<{
|
|||||||
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||||
glyph: 'K',
|
glyph: 'K',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
screen: 'cloud-save',
|
||||||
|
title: 'Backup Slot',
|
||||||
|
description: 'Choose local, online, or fresh IWT2 progress.',
|
||||||
|
glyph: 'C',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
screen: 'settings',
|
screen: 'settings',
|
||||||
title: '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 { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||||
|
const [arenaDifficulty, setArenaDifficulty] = useState<Iwt2Difficulty>(IWT2_DUNGEON_DIFFICULTIES[0])
|
||||||
|
const [selectedDungeonDifficultySlug, setSelectedDungeonDifficultySlug] = useState(IWT2_DUNGEON_DIFFICULTIES[0].slug)
|
||||||
|
const [selectedRaidDifficultySlug, setSelectedRaidDifficultySlug] = useState(IWT2_RAID_DIFFICULTIES[0].slug)
|
||||||
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||||
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||||
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||||
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||||
|
const [pvpQueueing, setPvpQueueing] = useState(false)
|
||||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,9 +169,16 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
cancelPvpQueueRef.current?.()
|
cancelPvpQueueRef.current?.()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
function cancelIwt2PvpQueue() {
|
||||||
|
cancelPvpQueueRef.current?.()
|
||||||
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueing(false)
|
||||||
|
setPvpQueueMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
||||||
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
|
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)),
|
||||||
[save, screen, selectedBossId],
|
[save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug],
|
||||||
)
|
)
|
||||||
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||||
() => buildIwt2WorkshopDualScreenState(screen, save),
|
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||||
@@ -191,7 +224,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
return (
|
return (
|
||||||
<BossArenaScreen
|
<BossArenaScreen
|
||||||
bossId={selectedBossId}
|
bossId={selectedBossId}
|
||||||
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
difficulty={arenaDifficulty}
|
||||||
|
key={`arena-${selectedBossId}-${arenaModeLabel}-${arenaDifficulty.slug}`}
|
||||||
modeLabel={arenaModeLabel}
|
modeLabel={arenaModeLabel}
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
@@ -224,6 +258,10 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
}}
|
}}
|
||||||
save={save}
|
save={save}
|
||||||
onBack={() => setScreen('roguelike')}
|
onBack={() => setScreen('roguelike')}
|
||||||
|
onPvpRequeue={() => {
|
||||||
|
setScreen('roguelike')
|
||||||
|
startIwt2RoguelikeRun()
|
||||||
|
}}
|
||||||
onSaveUpdated={setSave}
|
onSaveUpdated={setSave}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -258,12 +296,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2DungeonsScreen
|
<Iwt2DungeonsScreen
|
||||||
|
difficultySlug={selectedDungeonDifficultySlug}
|
||||||
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onOpenBoss={(bossId) => {
|
onDifficultyChange={setSelectedDungeonDifficultySlug}
|
||||||
setArenaModeLabel('Dungeon')
|
onOpenBoss={(bossId, difficulty) => {
|
||||||
|
setArenaModeLabel(`${difficulty.name} Dungeon`)
|
||||||
|
setArenaDifficulty(difficulty)
|
||||||
setSelectedBossId(bossId)
|
setSelectedBossId(bossId)
|
||||||
setScreen('arena')
|
setScreen('arena')
|
||||||
}}
|
}}
|
||||||
|
onPreviewBoss={setSelectedBossId}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -276,20 +319,23 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<Iwt2RoguelikeScreen
|
<Iwt2RoguelikeScreen
|
||||||
contentType={roguelikeContentType}
|
contentType={roguelikeContentType}
|
||||||
variant={roguelikeVariant}
|
variant={roguelikeVariant}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => {
|
||||||
|
cancelIwt2PvpQueue()
|
||||||
|
setScreen('menu')
|
||||||
|
}}
|
||||||
|
onCancelQueue={cancelIwt2PvpQueue}
|
||||||
onContentTypeChange={setRoguelikeContentType}
|
onContentTypeChange={setRoguelikeContentType}
|
||||||
onStart={() => {
|
onStart={() => {
|
||||||
startIwt2RoguelikeRun()
|
startIwt2RoguelikeRun()
|
||||||
}}
|
}}
|
||||||
onVariantChange={(nextVariant) => {
|
onVariantChange={(nextVariant) => {
|
||||||
cancelPvpQueueRef.current?.()
|
cancelIwt2PvpQueue()
|
||||||
cancelPvpQueueRef.current = null
|
|
||||||
setPvpQueueMessage('')
|
|
||||||
setRoguelikeVariant(nextVariant)
|
setRoguelikeVariant(nextVariant)
|
||||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||||
setRoguelikeContentType('dungeon')
|
setRoguelikeContentType('dungeon')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
queueing={pvpQueueing}
|
||||||
queueMessage={pvpQueueMessage}
|
queueMessage={pvpQueueMessage}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@@ -301,13 +347,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2ModeScreen
|
<Iwt2ModeScreen
|
||||||
|
difficultySlug={selectedRaidDifficultySlug}
|
||||||
mode="Raids"
|
mode="Raids"
|
||||||
|
save={save}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onOpenBoss={(bossId) => {
|
onDifficultyChange={setSelectedRaidDifficultySlug}
|
||||||
setArenaModeLabel('Raid')
|
onOpenBoss={(bossId, difficulty) => {
|
||||||
|
setArenaModeLabel(`${difficulty.name} Raid`)
|
||||||
|
setArenaDifficulty(difficulty)
|
||||||
setSelectedBossId(bossId)
|
setSelectedBossId(bossId)
|
||||||
setScreen('arena')
|
setScreen('arena')
|
||||||
}}
|
}}
|
||||||
|
onPreviewBoss={setSelectedBossId}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -321,6 +372,24 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (screen === 'gear-upgrade') {
|
||||||
|
return (
|
||||||
|
<main className="game-shell iwt2-shell">
|
||||||
|
<Iwt2Header
|
||||||
|
save={save}
|
||||||
|
title="Gear Upgrade"
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onBackToGameSelect={onBackToGameSelect}
|
||||||
|
/>
|
||||||
|
<Iwt2GearUpgradeScreen
|
||||||
|
save={save}
|
||||||
|
onBack={() => setScreen('menu')}
|
||||||
|
onSaveUpdated={setSave}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (screen === 'customize-character') {
|
if (screen === 'customize-character') {
|
||||||
return (
|
return (
|
||||||
<main className="game-shell iwt2-shell">
|
<main className="game-shell iwt2-shell">
|
||||||
@@ -340,6 +409,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||||
<Iwt2CloudSaveScreen
|
<Iwt2CloudSaveScreen
|
||||||
save={save}
|
save={save}
|
||||||
|
onlineBackupsAvailable={onlineBackupsAvailable}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
onSaveUpdated={setSave}
|
onSaveUpdated={setSave}
|
||||||
/>
|
/>
|
||||||
@@ -362,14 +432,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||||
<div className="iwt2-menu-heading">
|
|
||||||
<p className="eyebrow">I Want To Heal 2</p>
|
|
||||||
<h1>Mode Select</h1>
|
|
||||||
</div>
|
|
||||||
<div className="iwt2-menu-grid">
|
<div className="iwt2-menu-grid">
|
||||||
{MENU_ITEMS.map((item, index) => (
|
{MENU_ITEMS.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||||
|
aria-label={`${item.title}. ${item.description}`}
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||||
key={`${item.screen}-${item.title}`}
|
key={`${item.screen}-${item.title}`}
|
||||||
@@ -391,28 +458,33 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
|||||||
)
|
)
|
||||||
|
|
||||||
function startIwt2RoguelikeRun() {
|
function startIwt2RoguelikeRun() {
|
||||||
cancelPvpQueueRef.current?.()
|
cancelIwt2PvpQueue()
|
||||||
cancelPvpQueueRef.current = null
|
|
||||||
setPvpQueueMessage('')
|
|
||||||
if (roguelikeVariant !== 'pvp') {
|
if (roguelikeVariant !== 'pvp') {
|
||||||
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const startStage = 1
|
const startStage = 1
|
||||||
|
setPvpQueueing(true)
|
||||||
|
setPvpQueueMessage('Queuing for PvP...')
|
||||||
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||||
contentType: roguelikeContentType,
|
contentType: roguelikeContentType,
|
||||||
startStage,
|
startStage,
|
||||||
gameMode: getGameMode(),
|
gameMode: getGameMode(),
|
||||||
liveMatchActive: () => false,
|
liveMatchActive: () => false,
|
||||||
onSearching: setPvpQueueMessage,
|
onSearching: (message) => {
|
||||||
|
setPvpQueueing(true)
|
||||||
|
setPvpQueueMessage(message)
|
||||||
|
},
|
||||||
onCpuMatch: (_difficulty, message) => {
|
onCpuMatch: (_difficulty, message) => {
|
||||||
cancelPvpQueueRef.current = null
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueing(false)
|
||||||
setPvpQueueMessage(message)
|
setPvpQueueMessage(message)
|
||||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||||
},
|
},
|
||||||
onLiveMatch: (...liveMatchArgs) => {
|
onLiveMatch: (...liveMatchArgs) => {
|
||||||
const message = liveMatchArgs[2]
|
const message = liveMatchArgs[2]
|
||||||
cancelPvpQueueRef.current = null
|
cancelPvpQueueRef.current = null
|
||||||
|
setPvpQueueing(false)
|
||||||
setPvpQueueMessage(message)
|
setPvpQueueMessage(message)
|
||||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||||
},
|
},
|
||||||
@@ -464,7 +536,14 @@ function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
|||||||
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||||
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||||
return {
|
return {
|
||||||
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
selfChoices: variant === 'pvp'
|
||||||
|
? chooseRunChoicesWithPreferredFirst(
|
||||||
|
selfCatalog,
|
||||||
|
IWT2_ROGUELIKE_CHOICE_COUNT,
|
||||||
|
isExtraTargetBuff,
|
||||||
|
IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE,
|
||||||
|
)
|
||||||
|
: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||||
debuffChoices: variant === 'pvp'
|
debuffChoices: variant === 'pvp'
|
||||||
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||||
: [],
|
: [],
|
||||||
@@ -532,23 +611,63 @@ function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
|||||||
return choices
|
return choices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseRunChoicesWithPreferredFirst<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
count: number,
|
||||||
|
preferred: (item: T) => boolean,
|
||||||
|
preferredChance: number,
|
||||||
|
): T[] {
|
||||||
|
if (count <= 0) return []
|
||||||
|
const pool = [...items]
|
||||||
|
const choices: T[] = []
|
||||||
|
const preferredPool = pool.filter(preferred)
|
||||||
|
if (preferredPool.length > 0 && Math.random() < preferredChance) {
|
||||||
|
const preferredChoice = preferredPool[Math.floor(Math.random() * preferredPool.length)]
|
||||||
|
const preferredIndex = pool.indexOf(preferredChoice)
|
||||||
|
if (preferredIndex >= 0) {
|
||||||
|
const [choice] = pool.splice(preferredIndex, 1)
|
||||||
|
if (choice) choices.push(choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (pool.length > 0 && choices.length < count) {
|
||||||
|
const index = Math.floor(Math.random() * pool.length)
|
||||||
|
const [choice] = pool.splice(index, 1)
|
||||||
|
if (choice) choices.push(choice)
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>) {
|
||||||
|
return choice.id.endsWith('-extra-target')
|
||||||
|
}
|
||||||
|
|
||||||
function Iwt2Header({
|
function Iwt2Header({
|
||||||
|
onBack,
|
||||||
onBackToGameSelect,
|
onBackToGameSelect,
|
||||||
save,
|
save,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
|
onBack?: () => void
|
||||||
onBackToGameSelect: () => void
|
onBackToGameSelect: () => void
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
||||||
<strong>Games</strong>
|
<strong>Games</strong>
|
||||||
</button>
|
</button>
|
||||||
|
{title && <strong className="iwt2-header-title">{title}</strong>}
|
||||||
<div className="character-summary">
|
<div className="character-summary">
|
||||||
<strong>{save.character.name}</strong>
|
<strong>{save.character.name}</strong>
|
||||||
<small>IWT2 Level {save.character.level}</small>
|
<small>IWT2 Level {save.character.level}</small>
|
||||||
<small>{save.character.experience} XP</small>
|
<small>{save.character.experience} XP</small>
|
||||||
</div>
|
</div>
|
||||||
|
{onBack && (
|
||||||
|
<button className="back-button iwt2-header-back" data-controller-nav="skip" onClick={onBack} type="button">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -557,30 +676,44 @@ function buildIwt2SetupDualScreenState(
|
|||||||
screen: Iwt2Screen,
|
screen: Iwt2Screen,
|
||||||
selectedBossId: Iwt2BossId,
|
selectedBossId: Iwt2BossId,
|
||||||
save: Iwt2Save,
|
save: Iwt2Save,
|
||||||
|
difficulty: Iwt2Difficulty,
|
||||||
): DualScreenSetupState | null {
|
): DualScreenSetupState | null {
|
||||||
if (screen !== 'dungeons' && screen !== 'raids') return null
|
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||||
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||||
const raid = screen === 'raids'
|
const raid = screen === 'raids'
|
||||||
|
const coinReward = iwt2BossCoinRewardFor(selectedBossId, difficulty.slug)
|
||||||
return {
|
return {
|
||||||
contentType: raid ? 'raid' : 'dungeon',
|
contentType: raid ? 'raid' : 'dungeon',
|
||||||
description: raid
|
description: raid
|
||||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
? `${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.`,
|
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery. Reward: ${coinReward.name}.`,
|
||||||
difficultyName: `IWT2 Level ${save.character.level}`,
|
difficultyName: difficulty.name,
|
||||||
experience: 125,
|
experience: Math.round(125 * difficulty.experienceMultiplier),
|
||||||
initials: boss.icon,
|
initials: boss.icon,
|
||||||
itemLevel: save.character.level,
|
itemLevel: difficulty.droppedItemLevel,
|
||||||
|
lockedReason: undefined,
|
||||||
stats: {
|
stats: {
|
||||||
damage: `${boss.meleeDamage}`,
|
damage: `${difficulty.damageMultiplier.toFixed(2)}x`,
|
||||||
health: `${boss.maxHealth}`,
|
health: `${difficulty.healthMultiplier.toFixed(2)}x`,
|
||||||
loot: 'IWT2',
|
loot: coinReward.name,
|
||||||
xp: '125',
|
xp: `${difficulty.experienceMultiplier.toFixed(1)}x`,
|
||||||
},
|
},
|
||||||
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||||
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
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(
|
function buildIwt2WorkshopDualScreenState(
|
||||||
screen: Iwt2Screen,
|
screen: Iwt2Screen,
|
||||||
save: Iwt2Save,
|
save: Iwt2Save,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function Iwt2BottomDisplay() {
|
|||||||
binding={activeBindings[action]}
|
binding={activeBindings[action]}
|
||||||
iconStyle="playstation"
|
iconStyle="playstation"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
|
<img alt="" aria-hidden="true" className="iwt2-bottom-target-icon" draggable={false} src={target.uiIconUrl} />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export function PartyFrames({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ background: meta.color }}>{meta.icon}</span>
|
<span className="iwt2-party-portrait" style={{ backgroundColor: meta.color }}>
|
||||||
|
<img alt="" aria-hidden="true" draggable={false} src={meta.uiIconUrl} />
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="iwt2-party-row-title">
|
<div className="iwt2-party-row-title">
|
||||||
<strong>{meta.name}</strong>
|
<strong>{meta.name}</strong>
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import type { Iwt2BossId } from './bosses'
|
|||||||
|
|
||||||
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
|
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
|
||||||
|
|
||||||
export type Iwt2BossMaterialReward = {
|
export type Iwt2BossCoinReward = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
itemLevel: number
|
||||||
|
glyph: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2BossPetReward = {
|
export type Iwt2BossPetReward = {
|
||||||
@@ -12,48 +15,115 @@ export type Iwt2BossPetReward = {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT2_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, Iwt2BossMaterialReward> = {
|
type Iwt2CoinTier = {
|
||||||
'barroth': { id: 'barroth-shell', name: 'Barroth Shell' },
|
idPrefix: string
|
||||||
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' },
|
namePrefix: string
|
||||||
|
rarity: Iwt2BossCoinReward['rarity']
|
||||||
|
itemLevel: number
|
||||||
|
glyph: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_COIN_TIERS: Record<string, Iwt2CoinTier> = {
|
||||||
|
initiate: { idPrefix: 'raw', namePrefix: 'Raw', rarity: 'common', itemLevel: 1, glyph: 'R' },
|
||||||
|
veteran: { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
|
||||||
|
champion: { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
|
||||||
|
mythic: { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
|
||||||
|
ascendant: { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
|
||||||
|
'raid-normal': { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
|
||||||
|
'raid-champion': { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
|
||||||
|
'raid-mythic': { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
|
||||||
|
'raid-ascendant': { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_BOSS_REWARD_DISPLAY_NAMES: Record<Iwt2BossId, string> = {
|
||||||
|
'barroth': 'Mirehorn',
|
||||||
|
'bulldrome': 'Bristlemaw',
|
||||||
|
'cinderback-ricochet': 'Cinderback Ricochet',
|
||||||
|
'crystal-bat-matriarch': 'Crystal Bat Matriarch',
|
||||||
|
'ember-mantis-duelist': 'Ember Mantis Duelist',
|
||||||
|
'great-jaggi': 'Packfang Alpha',
|
||||||
|
'hollowcrown-revenant': 'Hollowcrown Revenant',
|
||||||
|
'khezu': 'Palevolt Maw',
|
||||||
|
'obsidian-ram-golem': 'Obsidian Ram Golem',
|
||||||
|
'rathian': 'Verdant Wyrm',
|
||||||
|
'rimebastion': 'Rimebastion',
|
||||||
|
'sandglass-scorpion': 'Sandglass Scorpion',
|
||||||
|
'stormcoil-wyrm': 'Stormcoil Wyrm',
|
||||||
|
'tobi-kadachi': 'Stormtail',
|
||||||
|
'venom-orchid-hydra': 'Venom Orchid Hydra',
|
||||||
|
'yian-kut-ku': 'Emberquill',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_BOSS_COIN_DIFFICULTY_SLUGS = [
|
||||||
|
'initiate',
|
||||||
|
'veteran',
|
||||||
|
'champion',
|
||||||
|
'mythic',
|
||||||
|
'ascendant',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const IWT2_LEGACY_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, { id: string, name: string }> = {
|
||||||
|
'barroth': { id: 'barroth-shell', name: 'Mirehorn Shell' },
|
||||||
|
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bristlemaw Coin' },
|
||||||
'cinderback-ricochet': { id: 'cinderback-plate', name: 'Cinderback Plate' },
|
'cinderback-ricochet': { id: 'cinderback-plate', name: 'Cinderback Plate' },
|
||||||
'crystal-bat-matriarch': { id: 'crystal-bat-fang', name: 'Crystal Bat Fang' },
|
'crystal-bat-matriarch': { id: 'crystal-bat-fang', name: 'Crystal Bat Fang' },
|
||||||
'ember-mantis-duelist': { id: 'ember-mantis-blade', name: 'Ember Mantis Blade' },
|
'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' },
|
'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' },
|
'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' },
|
'rimebastion': { id: 'rimebastion-core', name: 'Rimebastion Core' },
|
||||||
'sandglass-scorpion': { id: 'sandglass-stinger', name: 'Sandglass Stinger' },
|
'sandglass-scorpion': { id: 'sandglass-stinger', name: 'Sandglass Stinger' },
|
||||||
'stormcoil-wyrm': { id: 'stormcoil-conductor', name: 'Stormcoil Conductor' },
|
'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' },
|
'venom-orchid-hydra': { id: 'venom-orchid-pod', name: 'Venom Orchid Pod' },
|
||||||
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' },
|
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Emberquill Scale' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
|
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
|
||||||
'barroth': { id: 'barroth-pet', name: 'Barroth Pet' },
|
'barroth': { id: 'barroth-pet', name: 'Mirehorn Pet' },
|
||||||
'bulldrome': { id: 'bulldrome-pet', name: 'Bulldrome Pet' },
|
'bulldrome': { id: 'bulldrome-pet', name: 'Bristlemaw Pet' },
|
||||||
'cinderback-ricochet': { id: 'cinderback-ricochet-pet', name: 'Cinderback Ricochet Pet' },
|
'cinderback-ricochet': { id: 'cinderback-ricochet-pet', name: 'Cinderback Ricochet Pet' },
|
||||||
'crystal-bat-matriarch': { id: 'crystal-bat-matriarch-pet', name: 'Crystal Bat Matriarch 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' },
|
'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' },
|
'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' },
|
'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' },
|
'rimebastion': { id: 'rimebastion-pet', name: 'Rimebastion Pet' },
|
||||||
'sandglass-scorpion': { id: 'sandglass-scorpion-pet', name: 'Sandglass Scorpion Pet' },
|
'sandglass-scorpion': { id: 'sandglass-scorpion-pet', name: 'Sandglass Scorpion Pet' },
|
||||||
'stormcoil-wyrm': { id: 'stormcoil-wyrm-pet', name: 'Stormcoil Wyrm 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' },
|
'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 {
|
export function iwt2BossCoinRewardFor(
|
||||||
return IWT2_BOSS_MATERIAL_REWARDS[bossId]
|
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 {
|
export function iwt2BossPetRewardFor(bossId: Iwt2BossId): Iwt2BossPetReward {
|
||||||
return IWT2_BOSS_PET_REWARDS[bossId]
|
return IWT2_BOSS_PET_REWARDS[bossId]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBossNameForCoin(bossId: Iwt2BossId): string {
|
||||||
|
return IWT2_BOSS_REWARD_DISPLAY_NAMES[bossId]
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export type Iwt2BossMetadata = {
|
|||||||
|
|
||||||
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'bulldrome',
|
id: 'bulldrome',
|
||||||
name: 'Bulldrome',
|
name: 'Bristlemaw',
|
||||||
icon: 'B',
|
icon: 'B',
|
||||||
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
|
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
@@ -167,26 +167,26 @@ const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 31,
|
radius: 31,
|
||||||
moveSpeed: 128,
|
moveSpeed: 128,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
meleeDamage: 9,
|
meleeDamage: 20,
|
||||||
meleeCooldown: 1.05,
|
meleeCooldown: 1.05,
|
||||||
chargeCooldown: 4.75,
|
chargeCooldown: 4.75,
|
||||||
chargeWindup: 0.55,
|
chargeWindup: 0.55,
|
||||||
chargeSpeed: 510,
|
chargeSpeed: 510,
|
||||||
chargeDamage: 32,
|
chargeDamage: 60,
|
||||||
chargeStunSeconds: 0.75,
|
chargeStunSeconds: 0.75,
|
||||||
chargeLength: 560,
|
chargeLength: 560,
|
||||||
chargeOvershoot: 120,
|
chargeOvershoot: 120,
|
||||||
chargeWidth: 62,
|
chargeWidth: 62,
|
||||||
slamWindup: 0.45,
|
slamWindup: 0.45,
|
||||||
slamRadius: 104,
|
slamRadius: 104,
|
||||||
slamDamage: 24,
|
slamDamage: 55,
|
||||||
slamStunSeconds: 0.75,
|
slamStunSeconds: 0.75,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'yian-kut-ku',
|
id: 'yian-kut-ku',
|
||||||
name: 'Yian Kut Ku',
|
name: 'Emberquill',
|
||||||
icon: 'Y',
|
icon: 'E',
|
||||||
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
|
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
spriteWidthScale: 5.9,
|
spriteWidthScale: 5.9,
|
||||||
@@ -198,7 +198,7 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 29,
|
radius: 29,
|
||||||
moveSpeed: 118,
|
moveSpeed: 118,
|
||||||
meleeRange: 56,
|
meleeRange: 56,
|
||||||
meleeDamage: 8,
|
meleeDamage: 12,
|
||||||
meleeCooldown: 1,
|
meleeCooldown: 1,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -215,10 +215,10 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
fireballCooldown: 3.6,
|
fireballCooldown: 3.6,
|
||||||
fireballWindup: 0.55,
|
fireballWindup: 0.55,
|
||||||
fireballSpeed: 285,
|
fireballSpeed: 285,
|
||||||
fireballDamage: 12,
|
fireballDamage: 32,
|
||||||
fireballRadius: 9,
|
fireballRadius: 9,
|
||||||
firePuddleRadius: 42,
|
firePuddleRadius: 42,
|
||||||
firePuddleDamage: 7,
|
firePuddleDamage: 15,
|
||||||
firePuddleSeconds: 8,
|
firePuddleSeconds: 8,
|
||||||
birdWaveThresholds: [0.9, 0.4],
|
birdWaveThresholds: [0.9, 0.4],
|
||||||
birdFlightCooldown: 10,
|
birdFlightCooldown: 10,
|
||||||
@@ -226,14 +226,14 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
birdFlightSpeed: 360,
|
birdFlightSpeed: 360,
|
||||||
birdHealth: 58,
|
birdHealth: 58,
|
||||||
birdRadius: 16,
|
birdRadius: 16,
|
||||||
birdContactDamage: 10,
|
birdContactDamage: 26,
|
||||||
birdStunSeconds: 0.75,
|
birdStunSeconds: 0.75,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'great-jaggi',
|
id: 'great-jaggi',
|
||||||
name: 'Great Jaggi',
|
name: 'Packfang Alpha',
|
||||||
icon: 'J',
|
icon: 'P',
|
||||||
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
|
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
spriteWidthScale: 5.9,
|
spriteWidthScale: 5.9,
|
||||||
@@ -241,11 +241,11 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
spriteYOffsetScale: -0.09,
|
spriteYOffsetScale: -0.09,
|
||||||
color: '#3f8f73',
|
color: '#3f8f73',
|
||||||
accentColor: '#b8f0aa',
|
accentColor: '#b8f0aa',
|
||||||
maxHealth: 620,
|
maxHealth: 1000,
|
||||||
radius: 27,
|
radius: 27,
|
||||||
moveSpeed: 146,
|
moveSpeed: 146,
|
||||||
meleeRange: 52,
|
meleeRange: 52,
|
||||||
meleeDamage: 7,
|
meleeDamage: 18,
|
||||||
meleeCooldown: 0.82,
|
meleeCooldown: 0.82,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -261,7 +261,7 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
slamStunSeconds: 0,
|
slamStunSeconds: 0,
|
||||||
packHowlCooldown: 5.8,
|
packHowlCooldown: 5.8,
|
||||||
packHowlWindup: 0.95,
|
packHowlWindup: 0.95,
|
||||||
packLaneDamage: 15,
|
packLaneDamage: 45,
|
||||||
packLaneStunSeconds: 0.45,
|
packLaneStunSeconds: 0.45,
|
||||||
packLaneWidth: 24,
|
packLaneWidth: 24,
|
||||||
packLaneCount: 3,
|
packLaneCount: 3,
|
||||||
@@ -269,8 +269,8 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
|
|
||||||
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'khezu',
|
id: 'khezu',
|
||||||
name: 'Khezu',
|
name: 'Palevolt Maw',
|
||||||
icon: 'K',
|
icon: 'P',
|
||||||
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
|
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
spriteWidthScale: 5.9,
|
spriteWidthScale: 5.9,
|
||||||
@@ -278,11 +278,11 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
spriteYOffsetScale: -0.12,
|
spriteYOffsetScale: -0.12,
|
||||||
color: '#d8d7c9',
|
color: '#d8d7c9',
|
||||||
accentColor: '#77d9ff',
|
accentColor: '#77d9ff',
|
||||||
maxHealth: 760,
|
maxHealth: 1100,
|
||||||
radius: 30,
|
radius: 30,
|
||||||
moveSpeed: 92,
|
moveSpeed: 92,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
meleeDamage: 10,
|
meleeDamage: 20,
|
||||||
meleeCooldown: 1.15,
|
meleeCooldown: 1.15,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -300,20 +300,20 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
thunderRingWindup: 0.9,
|
thunderRingWindup: 0.9,
|
||||||
thunderRingInnerRadius: 70,
|
thunderRingInnerRadius: 70,
|
||||||
thunderRingOuterRadius: 172,
|
thunderRingOuterRadius: 172,
|
||||||
thunderRingDamage: 22,
|
thunderRingDamage: 60,
|
||||||
thunderRingStunSeconds: 0.7,
|
thunderRingStunSeconds: 0.7,
|
||||||
lightningStrikeCooldown: 4.2,
|
lightningStrikeCooldown: 4.2,
|
||||||
lightningStrikeWindup: 0.78,
|
lightningStrikeWindup: 0.78,
|
||||||
lightningStrikeRadius: 46,
|
lightningStrikeRadius: 46,
|
||||||
lightningStrikeDamage: 17,
|
lightningStrikeDamage: 48,
|
||||||
lightningStrikeStunSeconds: 0.55,
|
lightningStrikeStunSeconds: 0.55,
|
||||||
lightningStrikeCount: 2,
|
lightningStrikeCount: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'rathian',
|
id: 'rathian',
|
||||||
name: 'Rathian',
|
name: 'Verdant Wyrm',
|
||||||
icon: 'R',
|
icon: 'V',
|
||||||
spriteUrl: '/iwt2/bosses/rathian-side-cel.png',
|
spriteUrl: '/iwt2/bosses/rathian-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
spriteWidthScale: 6.9,
|
spriteWidthScale: 6.9,
|
||||||
@@ -325,7 +325,7 @@ const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 34,
|
radius: 34,
|
||||||
moveSpeed: 118,
|
moveSpeed: 118,
|
||||||
meleeRange: 62,
|
meleeRange: 62,
|
||||||
meleeDamage: 11,
|
meleeDamage: 16,
|
||||||
meleeCooldown: 1.08,
|
meleeCooldown: 1.08,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -345,19 +345,19 @@ const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
tailSweepInnerRadius: 24,
|
tailSweepInnerRadius: 24,
|
||||||
tailSweepOuterRadius: 118,
|
tailSweepOuterRadius: 118,
|
||||||
tailSweepAngleRadians: Math.PI * 1.25,
|
tailSweepAngleRadians: Math.PI * 1.25,
|
||||||
tailSweepDamage: 23,
|
tailSweepDamage: 45,
|
||||||
tailSweepStunSeconds: 0.55,
|
tailSweepStunSeconds: 0.55,
|
||||||
poisonSpitCooldown: 6.2,
|
poisonSpitCooldown: 6.2,
|
||||||
poisonSpitWindup: 0.7,
|
poisonSpitWindup: 0.7,
|
||||||
poisonPuddleRadius: 46,
|
poisonPuddleRadius: 46,
|
||||||
poisonPuddleDamage: 6,
|
poisonPuddleDamage: 15,
|
||||||
poisonPuddleSeconds: 8,
|
poisonPuddleSeconds: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'barroth',
|
id: 'barroth',
|
||||||
name: 'Barroth',
|
name: 'Mirehorn',
|
||||||
icon: 'A',
|
icon: 'M',
|
||||||
spriteUrl: '/iwt2/bosses/barroth-side-cel.png',
|
spriteUrl: '/iwt2/bosses/barroth-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
spriteWidthScale: 6.3,
|
spriteWidthScale: 6.3,
|
||||||
@@ -369,7 +369,7 @@ const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 35,
|
radius: 35,
|
||||||
moveSpeed: 104,
|
moveSpeed: 104,
|
||||||
meleeRange: 60,
|
meleeRange: 60,
|
||||||
meleeDamage: 12,
|
meleeDamage: 16,
|
||||||
meleeCooldown: 1.18,
|
meleeCooldown: 1.18,
|
||||||
chargeCooldown: 0,
|
chargeCooldown: 0,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -388,7 +388,7 @@ const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
mudSprayWindup: 0.82,
|
mudSprayWindup: 0.82,
|
||||||
mudSprayRange: 172,
|
mudSprayRange: 172,
|
||||||
mudSprayAngleRadians: Math.PI * 0.58,
|
mudSprayAngleRadians: Math.PI * 0.58,
|
||||||
mudSprayDamage: 13,
|
mudSprayDamage: 28,
|
||||||
mudSprayStunSeconds: 0.25,
|
mudSprayStunSeconds: 0.25,
|
||||||
mudPuddleRadius: 40,
|
mudPuddleRadius: 40,
|
||||||
mudPuddleSeconds: 7.5,
|
mudPuddleSeconds: 7.5,
|
||||||
@@ -396,8 +396,8 @@ const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
|
|
||||||
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
|
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||||
id: 'tobi-kadachi',
|
id: 'tobi-kadachi',
|
||||||
name: 'Tobi Kadachi',
|
name: 'Stormtail',
|
||||||
icon: 'T',
|
icon: 'S',
|
||||||
spriteUrl: '/iwt2/bosses/tobi-kadachi-side-cel.png',
|
spriteUrl: '/iwt2/bosses/tobi-kadachi-side-cel.png',
|
||||||
spriteView: 'side',
|
spriteView: 'side',
|
||||||
spriteWidthScale: 6.7,
|
spriteWidthScale: 6.7,
|
||||||
@@ -409,7 +409,7 @@ const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 28,
|
radius: 28,
|
||||||
moveSpeed: 160,
|
moveSpeed: 160,
|
||||||
meleeRange: 53,
|
meleeRange: 53,
|
||||||
meleeDamage: 8,
|
meleeDamage: 13,
|
||||||
meleeCooldown: 0.78,
|
meleeCooldown: 0.78,
|
||||||
chargeCooldown: 2.2,
|
chargeCooldown: 2.2,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -428,14 +428,14 @@ const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
staticEnergyPerMelee: 15,
|
staticEnergyPerMelee: 15,
|
||||||
staticPounceWindup: 0.58,
|
staticPounceWindup: 0.58,
|
||||||
staticPounceSpeed: 560,
|
staticPounceSpeed: 560,
|
||||||
staticPounceDamage: 24,
|
staticPounceDamage: 46,
|
||||||
staticPounceStunSeconds: 0.6,
|
staticPounceStunSeconds: 0.6,
|
||||||
staticPounceLength: 520,
|
staticPounceLength: 520,
|
||||||
staticPounceWidth: 46,
|
staticPounceWidth: 46,
|
||||||
chainShockCooldown: 5.2,
|
chainShockCooldown: 5.2,
|
||||||
chainShockWindup: 0.85,
|
chainShockWindup: 0.85,
|
||||||
chainShockRadius: 86,
|
chainShockRadius: 86,
|
||||||
chainShockDamage: 15,
|
chainShockDamage: 34,
|
||||||
chainShockStunSeconds: 0.45,
|
chainShockStunSeconds: 0.45,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 37,
|
radius: 37,
|
||||||
moveSpeed: 86,
|
moveSpeed: 86,
|
||||||
meleeRange: 62,
|
meleeRange: 62,
|
||||||
meleeDamage: 12,
|
meleeDamage: 16,
|
||||||
meleeCooldown: 1.2,
|
meleeCooldown: 1.2,
|
||||||
chargeCooldown: 5.6,
|
chargeCooldown: 5.6,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -474,8 +474,8 @@ const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
iceWallCount: 3,
|
iceWallCount: 3,
|
||||||
iceWallLength: 180,
|
iceWallLength: 180,
|
||||||
iceWallWidth: 22,
|
iceWallWidth: 22,
|
||||||
iceWallDamage: 10,
|
iceWallDamage: 18,
|
||||||
iceShardDamage: 22,
|
iceShardDamage: 34,
|
||||||
iceShardStunSeconds: 0.55,
|
iceShardStunSeconds: 0.55,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 29,
|
radius: 29,
|
||||||
moveSpeed: 168,
|
moveSpeed: 168,
|
||||||
meleeRange: 54,
|
meleeRange: 54,
|
||||||
meleeDamage: 9,
|
meleeDamage: 13,
|
||||||
meleeCooldown: 0.78,
|
meleeCooldown: 0.78,
|
||||||
chargeCooldown: 3.8,
|
chargeCooldown: 3.8,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -510,7 +510,7 @@ const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
slamStunSeconds: 0,
|
slamStunSeconds: 0,
|
||||||
slashCooldown: 4.2,
|
slashCooldown: 4.2,
|
||||||
slashWindup: 0.48,
|
slashWindup: 0.48,
|
||||||
slashDamage: 21,
|
slashDamage: 36,
|
||||||
slashStunSeconds: 0.35,
|
slashStunSeconds: 0.35,
|
||||||
slashWidth: 24,
|
slashWidth: 24,
|
||||||
crossSlashAngleRadians: Math.PI * 0.32,
|
crossSlashAngleRadians: Math.PI * 0.32,
|
||||||
@@ -532,7 +532,7 @@ const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 34,
|
radius: 34,
|
||||||
moveSpeed: 106,
|
moveSpeed: 106,
|
||||||
meleeRange: 60,
|
meleeRange: 60,
|
||||||
meleeDamage: 11,
|
meleeDamage: 14,
|
||||||
meleeCooldown: 1.05,
|
meleeCooldown: 1.05,
|
||||||
chargeCooldown: 5.4,
|
chargeCooldown: 5.4,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -549,15 +549,15 @@ const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
ricochetCooldown: 6.6,
|
ricochetCooldown: 6.6,
|
||||||
ricochetWindup: 0.65,
|
ricochetWindup: 0.65,
|
||||||
ricochetSpeed: 440,
|
ricochetSpeed: 440,
|
||||||
ricochetDamage: 24,
|
ricochetDamage: 40,
|
||||||
ricochetStunSeconds: 0.45,
|
ricochetStunSeconds: 0.45,
|
||||||
ricochetSeconds: 1.45,
|
ricochetSeconds: 1.45,
|
||||||
lavaTrailRadius: 34,
|
lavaTrailRadius: 34,
|
||||||
lavaTrailDamage: 6,
|
lavaTrailDamage: 15,
|
||||||
lavaTrailSeconds: 5.5,
|
lavaTrailSeconds: 5.5,
|
||||||
armorSlamWindup: 0.42,
|
armorSlamWindup: 0.42,
|
||||||
armorSlamRadius: 86,
|
armorSlamRadius: 86,
|
||||||
armorSlamDamage: 18,
|
armorSlamDamage: 44,
|
||||||
armorSlamStunSeconds: 0.5,
|
armorSlamStunSeconds: 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +576,7 @@ const DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 36,
|
radius: 36,
|
||||||
moveSpeed: 98,
|
moveSpeed: 98,
|
||||||
meleeRange: 62,
|
meleeRange: 62,
|
||||||
meleeDamage: 9,
|
meleeDamage: 14,
|
||||||
meleeCooldown: 1.12,
|
meleeCooldown: 1.12,
|
||||||
chargeCooldown: 5.7,
|
chargeCooldown: 5.7,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -608,7 +608,7 @@ const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 30,
|
radius: 30,
|
||||||
moveSpeed: 142,
|
moveSpeed: 142,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
meleeDamage: 8,
|
meleeDamage: 12,
|
||||||
meleeCooldown: 0.95,
|
meleeCooldown: 0.95,
|
||||||
chargeCooldown: 5.1,
|
chargeCooldown: 5.1,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -625,7 +625,7 @@ const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
chainShockCooldown: 5,
|
chainShockCooldown: 5,
|
||||||
chainShockWindup: 0.78,
|
chainShockWindup: 0.78,
|
||||||
chainShockRadius: 94,
|
chainShockRadius: 94,
|
||||||
chainShockDamage: 13,
|
chainShockDamage: 18,
|
||||||
chainShockStunSeconds: 0.38,
|
chainShockStunSeconds: 0.38,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +644,7 @@ const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 35,
|
radius: 35,
|
||||||
moveSpeed: 84,
|
moveSpeed: 84,
|
||||||
meleeRange: 64,
|
meleeRange: 64,
|
||||||
meleeDamage: 8,
|
meleeDamage: 12,
|
||||||
meleeCooldown: 1.15,
|
meleeCooldown: 1.15,
|
||||||
chargeCooldown: 5.4,
|
chargeCooldown: 5.4,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -661,7 +661,7 @@ const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
poisonSpitCooldown: 5.6,
|
poisonSpitCooldown: 5.6,
|
||||||
poisonSpitWindup: 0.75,
|
poisonSpitWindup: 0.75,
|
||||||
poisonPuddleRadius: 42,
|
poisonPuddleRadius: 42,
|
||||||
poisonPuddleDamage: 5,
|
poisonPuddleDamage: 15,
|
||||||
poisonPuddleSeconds: 7,
|
poisonPuddleSeconds: 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,11 +676,11 @@ const DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
spriteYOffsetScale: -0.07,
|
spriteYOffsetScale: -0.07,
|
||||||
color: '#9d7430',
|
color: '#9d7430',
|
||||||
accentColor: '#ffd15a',
|
accentColor: '#ffd15a',
|
||||||
maxHealth: 1180,
|
maxHealth: 1245,
|
||||||
radius: 33,
|
radius: 33,
|
||||||
moveSpeed: 126,
|
moveSpeed: 126,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
meleeDamage: 8,
|
meleeDamage: 13,
|
||||||
meleeCooldown: 0.98,
|
meleeCooldown: 0.98,
|
||||||
chargeCooldown: 5.2,
|
chargeCooldown: 5.2,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -713,7 +713,7 @@ const DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 31,
|
radius: 31,
|
||||||
moveSpeed: 150,
|
moveSpeed: 150,
|
||||||
meleeRange: 56,
|
meleeRange: 56,
|
||||||
meleeDamage: 7,
|
meleeDamage: 11,
|
||||||
meleeCooldown: 0.9,
|
meleeCooldown: 0.9,
|
||||||
chargeCooldown: 5.1,
|
chargeCooldown: 5.1,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
@@ -744,7 +744,7 @@ const DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
radius: 30,
|
radius: 30,
|
||||||
moveSpeed: 158,
|
moveSpeed: 158,
|
||||||
meleeRange: 56,
|
meleeRange: 56,
|
||||||
meleeDamage: 8,
|
meleeDamage: 12,
|
||||||
meleeCooldown: 0.9,
|
meleeCooldown: 0.9,
|
||||||
chargeCooldown: 4.8,
|
chargeCooldown: 4.8,
|
||||||
chargeWindup: 0,
|
chargeWindup: 0,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type Iwt2ClassMetadata = {
|
|||||||
name: string
|
name: string
|
||||||
role: Iwt2PlayerClassRole
|
role: Iwt2PlayerClassRole
|
||||||
icon: string
|
icon: string
|
||||||
|
arenaSpriteUrl: string
|
||||||
|
uiIconUrl: string
|
||||||
color: string
|
color: string
|
||||||
accentColor: string
|
accentColor: string
|
||||||
maxHealth: number
|
maxHealth: number
|
||||||
@@ -25,6 +27,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Player Healer',
|
name: 'Player Healer',
|
||||||
role: 'healer',
|
role: 'healer',
|
||||||
icon: '+',
|
icon: '+',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/healer-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/healer-medallion.png',
|
||||||
color: '#2fbf71',
|
color: '#2fbf71',
|
||||||
accentColor: '#b7f7cf',
|
accentColor: '#b7f7cf',
|
||||||
maxHealth: 125,
|
maxHealth: 125,
|
||||||
@@ -41,6 +45,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Paladin Tank',
|
name: 'Paladin Tank',
|
||||||
role: 'tank',
|
role: 'tank',
|
||||||
icon: '🛡',
|
icon: '🛡',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/paladin-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/paladin-medallion.png',
|
||||||
color: '#f2c94c',
|
color: '#f2c94c',
|
||||||
accentColor: '#fff0a8',
|
accentColor: '#fff0a8',
|
||||||
maxHealth: 190,
|
maxHealth: 190,
|
||||||
@@ -57,6 +63,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Ranger',
|
name: 'Ranger',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '🏹',
|
icon: '🏹',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/ranger-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/ranger-medallion.png',
|
||||||
color: '#4aa3df',
|
color: '#4aa3df',
|
||||||
accentColor: '#b9e3ff',
|
accentColor: '#b9e3ff',
|
||||||
maxHealth: 115,
|
maxHealth: 115,
|
||||||
@@ -73,6 +81,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Mage',
|
name: 'Mage',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '⚚',
|
icon: '⚚',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/mage-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/mage-medallion.png',
|
||||||
color: '#c56cf0',
|
color: '#c56cf0',
|
||||||
accentColor: '#efc4ff',
|
accentColor: '#efc4ff',
|
||||||
maxHealth: 100,
|
maxHealth: 100,
|
||||||
@@ -89,6 +99,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Rogue',
|
name: 'Rogue',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '††',
|
icon: '††',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/rogue-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/rogue-medallion.png',
|
||||||
color: '#55efc4',
|
color: '#55efc4',
|
||||||
accentColor: '#c8fff2',
|
accentColor: '#c8fff2',
|
||||||
maxHealth: 110,
|
maxHealth: 110,
|
||||||
@@ -105,6 +117,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
name: 'Warrior',
|
name: 'Warrior',
|
||||||
role: 'damage',
|
role: 'damage',
|
||||||
icon: '⚔',
|
icon: '⚔',
|
||||||
|
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
|
||||||
|
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
|
||||||
color: '#ff7675',
|
color: '#ff7675',
|
||||||
accentColor: '#ffd0d0',
|
accentColor: '#ffd0d0',
|
||||||
maxHealth: 150,
|
maxHealth: 150,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type { Iwt2BossId } from './bosses'
|
||||||
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
|
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||||
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||||
|
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
|
||||||
|
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
||||||
|
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
|
||||||
|
export type Iwt2GearStatId =
|
||||||
|
| 'maxHealth'
|
||||||
|
| 'moveSpeed'
|
||||||
|
| 'healingPower'
|
||||||
|
| 'damage'
|
||||||
|
| 'attackCooldown'
|
||||||
|
| 'projectileSpeed'
|
||||||
|
| 'stunResist'
|
||||||
|
| 'hazardDamageTaken'
|
||||||
|
|
||||||
|
export type Iwt2GearSlotProgress = {
|
||||||
|
level: Iwt2GearLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2ClassGearProgress = {
|
||||||
|
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||||
|
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
||||||
|
|
||||||
|
export type Iwt2GearUpgradeCost = {
|
||||||
|
itemId: string
|
||||||
|
itemName: string
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2GearSlotRecipe = {
|
||||||
|
classId: Iwt2PlayerClassId
|
||||||
|
slotId: Iwt2GearSlotId
|
||||||
|
primaryBossId: Iwt2BossId
|
||||||
|
secondaryBossId: Iwt2BossId
|
||||||
|
statId: Iwt2GearStatId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
||||||
|
|
||||||
|
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||||
|
weapon: 'Weapon',
|
||||||
|
helmet: 'Helmet',
|
||||||
|
chest: 'Chest',
|
||||||
|
legs: 'Legs',
|
||||||
|
feet: 'Feet',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_GEAR_STAT_LABELS: Record<Iwt2GearStatId, string> = {
|
||||||
|
maxHealth: 'Max Health',
|
||||||
|
moveSpeed: 'Move Speed',
|
||||||
|
healingPower: 'Healing Power',
|
||||||
|
damage: 'Damage',
|
||||||
|
attackCooldown: 'Cooldown',
|
||||||
|
projectileSpeed: 'Projectile Speed',
|
||||||
|
stunResist: 'Stun Resist',
|
||||||
|
hazardDamageTaken: 'Hazard Damage Taken',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_GEAR_SLOT_RECIPES: Record<Iwt2PlayerClassId, Record<Iwt2GearSlotId, Iwt2GearSlotRecipe>> = {
|
||||||
|
healer: {
|
||||||
|
weapon: slotRecipe('healer', 'weapon', 'rathian', 'yian-kut-ku', 'healingPower'),
|
||||||
|
helmet: slotRecipe('healer', 'helmet', 'khezu', 'rimebastion', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('healer', 'chest', 'barroth', 'obsidian-ram-golem', 'maxHealth'),
|
||||||
|
legs: slotRecipe('healer', 'legs', 'tobi-kadachi', 'great-jaggi', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('healer', 'feet', 'venom-orchid-hydra', 'sandglass-scorpion', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
paladin: {
|
||||||
|
weapon: slotRecipe('paladin', 'weapon', 'obsidian-ram-golem', 'bulldrome', 'damage'),
|
||||||
|
helmet: slotRecipe('paladin', 'helmet', 'khezu', 'hollowcrown-revenant', 'stunResist'),
|
||||||
|
chest: slotRecipe('paladin', 'chest', 'barroth', 'rimebastion', 'maxHealth'),
|
||||||
|
legs: slotRecipe('paladin', 'legs', 'bulldrome', 'tobi-kadachi', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('paladin', 'feet', 'sandglass-scorpion', 'great-jaggi', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
ranger: {
|
||||||
|
weapon: slotRecipe('ranger', 'weapon', 'cinderback-ricochet', 'yian-kut-ku', 'damage'),
|
||||||
|
helmet: slotRecipe('ranger', 'helmet', 'crystal-bat-matriarch', 'stormcoil-wyrm', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('ranger', 'chest', 'great-jaggi', 'barroth', 'maxHealth'),
|
||||||
|
legs: slotRecipe('ranger', 'legs', 'tobi-kadachi', 'crystal-bat-matriarch', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('ranger', 'feet', 'sandglass-scorpion', 'venom-orchid-hydra', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
mage: {
|
||||||
|
weapon: slotRecipe('mage', 'weapon', 'stormcoil-wyrm', 'yian-kut-ku', 'damage'),
|
||||||
|
helmet: slotRecipe('mage', 'helmet', 'rimebastion', 'khezu', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('mage', 'chest', 'venom-orchid-hydra', 'barroth', 'maxHealth'),
|
||||||
|
legs: slotRecipe('mage', 'legs', 'tobi-kadachi', 'crystal-bat-matriarch', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('mage', 'feet', 'hollowcrown-revenant', 'sandglass-scorpion', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
rogue: {
|
||||||
|
weapon: slotRecipe('rogue', 'weapon', 'ember-mantis-duelist', 'rathian', 'damage'),
|
||||||
|
helmet: slotRecipe('rogue', 'helmet', 'crystal-bat-matriarch', 'khezu', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('rogue', 'chest', 'great-jaggi', 'barroth', 'maxHealth'),
|
||||||
|
legs: slotRecipe('rogue', 'legs', 'tobi-kadachi', 'ember-mantis-duelist', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('rogue', 'feet', 'hollowcrown-revenant', 'sandglass-scorpion', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
warrior: {
|
||||||
|
weapon: slotRecipe('warrior', 'weapon', 'bulldrome', 'ember-mantis-duelist', 'damage'),
|
||||||
|
helmet: slotRecipe('warrior', 'helmet', 'obsidian-ram-golem', 'khezu', 'attackCooldown'),
|
||||||
|
chest: slotRecipe('warrior', 'chest', 'barroth', 'rimebastion', 'maxHealth'),
|
||||||
|
legs: slotRecipe('warrior', 'legs', 'great-jaggi', 'tobi-kadachi', 'moveSpeed'),
|
||||||
|
feet: slotRecipe('warrior', 'feet', 'rathian', 'venom-orchid-hydra', 'hazardDamageTaken'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
||||||
|
return {
|
||||||
|
healer: createDefaultClassGearProgress(),
|
||||||
|
paladin: createDefaultClassGearProgress(),
|
||||||
|
ranger: createDefaultClassGearProgress(),
|
||||||
|
mage: createDefaultClassGearProgress(),
|
||||||
|
rogue: createDefaultClassGearProgress(),
|
||||||
|
warrior: createDefaultClassGearProgress(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
||||||
|
return Object.values(progress.slots).some((slot) => slot.level >= 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2GearUpgradeCosts(
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
currentLevel: Iwt2GearLevel,
|
||||||
|
): Iwt2GearUpgradeCost[] {
|
||||||
|
if (currentLevel >= 5) return []
|
||||||
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
|
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||||
|
const slug = upgradeDifficultySlug(nextLevel)
|
||||||
|
const primary = iwt2BossCoinRewardFor(recipe.primaryBossId, slug)
|
||||||
|
const secondary = iwt2BossCoinRewardFor(recipe.secondaryBossId, slug)
|
||||||
|
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }]
|
||||||
|
if (nextLevel === 2) return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 3 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 1 },
|
||||||
|
]
|
||||||
|
if (nextLevel === 3) return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 3 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 2 },
|
||||||
|
]
|
||||||
|
if (nextLevel === 4) return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2InfusionCosts(
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
abilityId: Iwt2InfusionAbilityId,
|
||||||
|
): Iwt2GearUpgradeCost[] {
|
||||||
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
|
const linked = iwt2BossCoinRewardFor(ability.linkedBossId, 'ascendant')
|
||||||
|
const slotPrimary = iwt2BossCoinRewardFor(recipe.primaryBossId, 'mythic')
|
||||||
|
return [
|
||||||
|
{ itemId: linked.id, itemName: linked.name, quantity: 5 },
|
||||||
|
{ itemId: slotPrimary.id, itemName: slotPrimary.name, quantity: 5 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
||||||
|
return {
|
||||||
|
slots: {
|
||||||
|
weapon: { level: 0 },
|
||||||
|
helmet: { level: 0 },
|
||||||
|
chest: { level: 0 },
|
||||||
|
legs: { level: 0 },
|
||||||
|
feet: { level: 0 },
|
||||||
|
},
|
||||||
|
infusionAbilityId: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotRecipe(
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
slotId: Iwt2GearSlotId,
|
||||||
|
primaryBossId: Iwt2BossId,
|
||||||
|
secondaryBossId: Iwt2BossId,
|
||||||
|
statId: Iwt2GearStatId,
|
||||||
|
): Iwt2GearSlotRecipe {
|
||||||
|
return { classId, primaryBossId, secondaryBossId, slotId, statId }
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||||
|
if (level <= 2) return 'initiate'
|
||||||
|
if (level === 3) return 'veteran'
|
||||||
|
if (level === 4) return 'champion'
|
||||||
|
return 'mythic'
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Spell } from '../../../game'
|
import type { Spell } from '../../../game'
|
||||||
|
import type { Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import { iwt2HealerInfusionAbility } from './healerInfusionAbilities'
|
||||||
|
|
||||||
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground'
|
||||||
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
export type Iwt2HealerId = 'dawnweaver' | 'lifebinder' | 'runesage'
|
||||||
@@ -23,6 +25,15 @@ export type Iwt2HealerAbility = {
|
|||||||
cooldownSeconds: number
|
cooldownSeconds: number
|
||||||
target: Iwt2AbilityTarget
|
target: Iwt2AbilityTarget
|
||||||
extraTargets?: number
|
extraTargets?: number
|
||||||
|
triggeredEffects?: Iwt2TriggeredAbilityEffect[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Iwt2TriggeredAbilityEffect = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: Spell['kind']
|
||||||
|
power: number
|
||||||
|
effectType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
export const IWT2_HEALER_METADATA: Record<Iwt2HealerId, Iwt2HealerMetadata> = {
|
||||||
@@ -55,7 +66,6 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
|
|||||||
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
createAbility('dawnweaver', 3, 'radiance', 'Radiance', '*', 'group', 18, 12, 8),
|
||||||
createAbility('dawnweaver', 4, 'sun-ward', 'Sun Ward', 'O', 'shield', 36, 8, 7, 'shield'),
|
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', 5, 'purify', 'Purify', 'x', 'cleanse', 10, 5, 5, 'cleanse'),
|
||||||
createAbility('dawnweaver', 6, 'dawn-burst', 'Dawn Burst', 'D', 'group', 28, 16, 12),
|
|
||||||
],
|
],
|
||||||
lifebinder: [
|
lifebinder: [
|
||||||
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
createAbility('lifebinder', 1, 'verdant-touch', 'Verdant Touch', '+', 'direct', 24, 4, 0.55),
|
||||||
@@ -63,7 +73,6 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
|
|||||||
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
|
createAbility('lifebinder', 3, 'wild-growth', 'Wild Growth', '*', 'group', 15, 11, 7.5),
|
||||||
createAbility('lifebinder', 4, 'barkskin', 'Barkskin', 'O', 'shield', 46, 10, 5.5, 'shield'),
|
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', 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: [
|
runesage: [
|
||||||
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
createAbility('runesage', 1, 'etched-mend', 'Etched Mend', '+', 'direct', 22, 3, 0.35),
|
||||||
@@ -71,12 +80,13 @@ export const IWT2_HEALER_ABILITIES: Record<Iwt2HealerId, Iwt2HealerAbility[]> =
|
|||||||
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
|
createAbility('runesage', 3, 'concordance', 'Concordance', '*', 'group', 16, 9, 5.5),
|
||||||
createAbility('runesage', 4, 'aegis-script', 'Aegis Script', 'O', 'shield', 28, 6, 4.5, 'shield'),
|
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', 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) {
|
export function abilitiesForHealer(healerId: Iwt2HealerId | string, infusionAbilityId?: Iwt2InfusionAbilityId | null) {
|
||||||
return IWT2_HEALER_ABILITIES[asIwt2HealerId(healerId)]
|
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 {
|
export function asIwt2HealerId(value: unknown): Iwt2HealerId {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import type { Iwt2HealerAbility, Iwt2HealerId } from './healerAbilities'
|
||||||
|
|
||||||
|
export function iwt2HealerInfusionAbility(
|
||||||
|
infusionAbilityId: Iwt2InfusionAbilityId,
|
||||||
|
healerId: Iwt2HealerId,
|
||||||
|
): Iwt2HealerAbility | null {
|
||||||
|
const infusion = IWT2_INFUSION_ABILITIES[infusionAbilityId]
|
||||||
|
if (!infusion || infusion.classId !== 'healer') return null
|
||||||
|
if (infusion.effectKey === 'sanctuary') {
|
||||||
|
return createInfusedHealerAbility(healerId, 'sanctuary', infusion.name, infusion.icon, 'shield', 50, 16, infusion.cooldownSeconds ?? 45, 'shield')
|
||||||
|
}
|
||||||
|
if (infusion.effectKey === 'guardianPulse') {
|
||||||
|
return createInfusedHealerAbility(healerId, 'guardian-pulse', infusion.name, infusion.icon, 'group', 26, 14, infusion.cooldownSeconds ?? 40)
|
||||||
|
}
|
||||||
|
if (infusion.effectKey === 'emergencyMiracle') {
|
||||||
|
return createInfusedHealerAbility(healerId, 'emergency-miracle', infusion.name, infusion.icon, 'direct', 90, 20, infusion.cooldownSeconds ?? 75)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInfusedHealerAbility(
|
||||||
|
healerId: Iwt2HealerId,
|
||||||
|
slug: string,
|
||||||
|
name: string,
|
||||||
|
icon: string,
|
||||||
|
kind: Iwt2HealerAbility['kind'],
|
||||||
|
power: number,
|
||||||
|
manaCost: number,
|
||||||
|
cooldownSeconds: number,
|
||||||
|
effectType?: string,
|
||||||
|
): Iwt2HealerAbility {
|
||||||
|
return {
|
||||||
|
cooldownSeconds,
|
||||||
|
effectType,
|
||||||
|
healerId,
|
||||||
|
icon,
|
||||||
|
id: `${healerId}-infusion-${slug}`,
|
||||||
|
kind,
|
||||||
|
manaCost,
|
||||||
|
name,
|
||||||
|
power,
|
||||||
|
slot: 6,
|
||||||
|
target: 'party-member',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Iwt2BossId } from './bosses'
|
||||||
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbilityId =
|
||||||
|
| 'healer-sanctuary'
|
||||||
|
| 'healer-guardian-pulse'
|
||||||
|
| 'healer-emergency-miracle'
|
||||||
|
| 'paladin-unbreakable'
|
||||||
|
| 'paladin-bulwark-field'
|
||||||
|
| 'paladin-intercept'
|
||||||
|
| 'ranger-decoy-companion'
|
||||||
|
| 'ranger-pinning-volley'
|
||||||
|
| 'ranger-hunters-mark'
|
||||||
|
| 'mage-panic-blink'
|
||||||
|
| 'mage-arcane-barrier'
|
||||||
|
| 'mage-overcharge'
|
||||||
|
| 'rogue-shadow-step'
|
||||||
|
| 'rogue-vanish'
|
||||||
|
| 'rogue-expose-weakness'
|
||||||
|
| 'warrior-bladestorm'
|
||||||
|
| 'warrior-iron-rush'
|
||||||
|
| 'warrior-blood-rally'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbilityTrigger =
|
||||||
|
| 'active'
|
||||||
|
| 'danger'
|
||||||
|
| 'passive'
|
||||||
|
| 'postMechanic'
|
||||||
|
| 'survival'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbilityEffectKey =
|
||||||
|
| 'arcaneBarrier'
|
||||||
|
| 'bladestorm'
|
||||||
|
| 'bloodRally'
|
||||||
|
| 'bulwarkField'
|
||||||
|
| 'decoyCompanion'
|
||||||
|
| 'emergencyMiracle'
|
||||||
|
| 'exposeWeakness'
|
||||||
|
| 'guardianPulse'
|
||||||
|
| 'huntersMark'
|
||||||
|
| 'intercept'
|
||||||
|
| 'ironRush'
|
||||||
|
| 'overcharge'
|
||||||
|
| 'panicBlink'
|
||||||
|
| 'pinningVolley'
|
||||||
|
| 'sanctuary'
|
||||||
|
| 'shadowStep'
|
||||||
|
| 'unbreakable'
|
||||||
|
| 'vanish'
|
||||||
|
|
||||||
|
export type Iwt2InfusionAbility = {
|
||||||
|
id: Iwt2InfusionAbilityId
|
||||||
|
classId: Iwt2PlayerClassId
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
linkedBossId: Iwt2BossId
|
||||||
|
cooldownSeconds: number | null
|
||||||
|
description: string
|
||||||
|
trigger: Iwt2InfusionAbilityTrigger
|
||||||
|
effectKey: Iwt2InfusionAbilityEffectKey
|
||||||
|
slot: 6
|
||||||
|
reuseTags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_INFUSION_ABILITIES: Record<Iwt2InfusionAbilityId, Iwt2InfusionAbility> = {
|
||||||
|
'healer-sanctuary': infusion('healer-sanctuary', 'healer', 'Sanctuary', 'S', 'rimebastion', 45, 'Place a safe zone that reduces hazard damage for party members inside it.', 'active', 'sanctuary', ['healer', 'hazard-counter', 'zone']),
|
||||||
|
'healer-guardian-pulse': infusion('healer-guardian-pulse', 'healer', 'Guardian Pulse', 'G', 'khezu', 40, 'Shield the party after a major boss telegraph resolves.', 'postMechanic', 'guardianPulse', ['healer', 'shield', 'telegraph-counter']),
|
||||||
|
'healer-emergency-miracle': infusion('healer-emergency-miracle', 'healer', 'Emergency Miracle', 'M', 'hollowcrown-revenant', 75, 'Auto-cast a large heal on the lowest-health ally at danger threshold.', 'survival', 'emergencyMiracle', ['healer', 'survival', 'auto-save']),
|
||||||
|
'paladin-unbreakable': infusion('paladin-unbreakable', 'paladin', 'Unbreakable', 'U', 'bulldrome', null, 'Become immune to stuns and knockdowns.', 'passive', 'unbreakable', ['tank', 'control-immunity']),
|
||||||
|
'paladin-bulwark-field': infusion('paladin-bulwark-field', 'paladin', 'Bulwark Field', 'B', 'barroth', 35, 'Nearby party members take reduced damage.', 'active', 'bulwarkField', ['tank', 'aura', 'damage-reduction']),
|
||||||
|
'paladin-intercept': infusion('paladin-intercept', 'paladin', 'Intercept', 'I', 'obsidian-ram-golem', 30, 'Dash to an endangered party member and absorb the next hit.', 'danger', 'intercept', ['tank', 'dash', 'protection']),
|
||||||
|
'ranger-decoy-companion': infusion('ranger-decoy-companion', 'ranger', 'Decoy Companion', 'D', 'great-jaggi', 60, 'Summon a pet that holds boss attention for 8 seconds.', 'active', 'decoyCompanion', ['ranged', 'summon', 'aggro-decoy']),
|
||||||
|
'ranger-pinning-volley': infusion('ranger-pinning-volley', 'ranger', 'Pinning Volley', 'P', 'crystal-bat-matriarch', 35, 'Fire a periodic shot that briefly slows the boss.', 'active', 'pinningVolley', ['ranged', 'slow', 'projectile']),
|
||||||
|
'ranger-hunters-mark': infusion('ranger-hunters-mark', 'ranger', "Hunter's Mark", 'H', 'cinderback-ricochet', 45, 'Mark a boss so ranged hits deal increased damage briefly.', 'active', 'huntersMark', ['ranged', 'debuff', 'damage-window']),
|
||||||
|
'mage-panic-blink': infusion('mage-panic-blink', 'mage', 'Panic Blink', 'B', 'tobi-kadachi', 45, 'Teleport across the arena when danger scoring says the mage is trapped.', 'danger', 'panicBlink', ['caster', 'teleport', 'escape']),
|
||||||
|
'mage-arcane-barrier': infusion('mage-arcane-barrier', 'mage', 'Arcane Barrier', 'A', 'rimebastion', 60, 'Gain a shield before a lethal hit.', 'survival', 'arcaneBarrier', ['caster', 'shield', 'survival']),
|
||||||
|
'mage-overcharge': infusion('mage-overcharge', 'mage', 'Overcharge', 'O', 'stormcoil-wyrm', 40, 'Gain faster casts after avoiding a major mechanic.', 'postMechanic', 'overcharge', ['caster', 'haste', 'telegraph-reward']),
|
||||||
|
'rogue-shadow-step': infusion('rogue-shadow-step', 'rogue', 'Shadow Step', 'S', 'ember-mantis-duelist', 30, 'Teleport to any party member, then gain 30% damage for 8 seconds.', 'active', 'shadowStep', ['melee', 'teleport', 'burst']),
|
||||||
|
'rogue-vanish': infusion('rogue-vanish', 'rogue', 'Vanish', 'V', 'hollowcrown-revenant', 45, 'Drop boss focus and evade the next hit.', 'survival', 'vanish', ['melee', 'evade', 'survival']),
|
||||||
|
'rogue-expose-weakness': infusion('rogue-expose-weakness', 'rogue', 'Expose Weakness', 'E', 'rathian', 35, 'Flanking attacks increase party damage briefly.', 'active', 'exposeWeakness', ['melee', 'debuff', 'flank']),
|
||||||
|
'warrior-bladestorm': infusion('warrior-bladestorm', 'warrior', 'Bladestorm', 'B', 'bulldrome', 30, 'Spin for 5 seconds, damaging nearby enemies and taking 50% less damage.', 'active', 'bladestorm', ['melee', 'aoe', 'damage-reduction']),
|
||||||
|
'warrior-iron-rush': infusion('warrior-iron-rush', 'warrior', 'Iron Rush', 'I', 'obsidian-ram-golem', 35, 'Charge through the boss with brief damage reduction.', 'active', 'ironRush', ['melee', 'charge', 'damage-reduction']),
|
||||||
|
'warrior-blood-rally': infusion('warrior-blood-rally', 'warrior', 'Blood Rally', 'R', 'venom-orchid-hydra', 50, 'Gain damage when party health is low.', 'survival', 'bloodRally', ['melee', 'survival', 'damage-window']),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iwt2InfusionAbilitiesForClass(classId: Iwt2PlayerClassId): Iwt2InfusionAbility[] {
|
||||||
|
return Object.values(IWT2_INFUSION_ABILITIES).filter((ability) => ability.classId === classId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asIwt2InfusionAbilityId(value: unknown): Iwt2InfusionAbilityId | null {
|
||||||
|
return typeof value === 'string' && value in IWT2_INFUSION_ABILITIES
|
||||||
|
? value as Iwt2InfusionAbilityId
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function infusion(
|
||||||
|
id: Iwt2InfusionAbilityId,
|
||||||
|
classId: Iwt2PlayerClassId,
|
||||||
|
name: string,
|
||||||
|
icon: string,
|
||||||
|
linkedBossId: Iwt2BossId,
|
||||||
|
cooldownSeconds: number | null,
|
||||||
|
description: string,
|
||||||
|
trigger: Iwt2InfusionAbilityTrigger,
|
||||||
|
effectKey: Iwt2InfusionAbilityEffectKey,
|
||||||
|
reuseTags: string[],
|
||||||
|
): Iwt2InfusionAbility {
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
cooldownSeconds,
|
||||||
|
description,
|
||||||
|
effectKey,
|
||||||
|
icon,
|
||||||
|
id,
|
||||||
|
linkedBossId,
|
||||||
|
name,
|
||||||
|
reuseTags,
|
||||||
|
slot: 6,
|
||||||
|
trigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,33 @@ export type Iwt2RoguelikeSlot = '1' | '2' | '3' | '4' | '5'
|
|||||||
|
|
||||||
export type Iwt2RoguelikeSelfBuffId =
|
export type Iwt2RoguelikeSelfBuffId =
|
||||||
| 'revive-party-members'
|
| '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}-extra-target`
|
||||||
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
| `slot${Iwt2RoguelikeSlot}-cost-down`
|
||||||
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
| `slot${Iwt2RoguelikeSlot}-cooldown-down`
|
||||||
@@ -26,6 +53,9 @@ export type Iwt2RoguelikeChoice<T extends string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
export const IWT2_ROGUELIKE_SLOTS: readonly Iwt2RoguelikeSlot[] = ['1', '2', '3', '4', '5']
|
||||||
|
export const IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID = 'dawnweaver-sun-ward-damage-reduction'
|
||||||
|
export const IWT2_BARKSKIN_HOT_BONUS_BUFF_ID = 'lifebinder-barkskin-hot-bonus'
|
||||||
|
export const IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID = 'runesage-aegis-script-damage-reduction'
|
||||||
|
|
||||||
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId> = {
|
||||||
id: 'revive-party-members',
|
id: 'revive-party-members',
|
||||||
@@ -33,6 +63,150 @@ export const IWT2_REVIVE_PARTY_CHOICE: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuff
|
|||||||
description: 'Revive fallen party members before the next IWT2 arena.',
|
description: 'Revive fallen party members before the next IWT2 arena.',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const IWT2_DAWNWEAVER_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-mend-applies-renew',
|
||||||
|
name: 'Mend Applies Renew',
|
||||||
|
description: 'Mend also applies Renew to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-mend-applies-sun-ward',
|
||||||
|
name: 'Mend Applies Sun Ward',
|
||||||
|
description: 'Mend also applies Sun Ward to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-renew-applies-sun-ward',
|
||||||
|
name: 'Renew Applies Sun Ward',
|
||||||
|
description: 'Renew also applies Sun Ward to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-radiance-applies-renew',
|
||||||
|
name: 'Radiance Applies Renew',
|
||||||
|
description: 'Radiance also applies Renew to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-radiance-applies-sun-ward',
|
||||||
|
name: 'Radiance Applies 50% Sun Ward',
|
||||||
|
description: 'Radiance also applies Sun Ward at 50% strength to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-sun-ward-applies-renew',
|
||||||
|
name: 'Sun Ward Applies Renew',
|
||||||
|
description: 'Sun Ward also applies Renew to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
|
name: 'Sun Ward Reduces Damage',
|
||||||
|
description: 'While shielded by Sun Ward, the target takes 50% less damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-purify-applies-renew',
|
||||||
|
name: 'Purify Applies Renew',
|
||||||
|
description: 'Purify also applies Renew to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dawnweaver-purify-applies-sun-ward',
|
||||||
|
name: 'Purify Applies Sun Ward',
|
||||||
|
description: 'Purify also applies Sun Ward to the target.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IWT2_LIFEBINDER_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
|
||||||
|
{
|
||||||
|
id: 'lifebinder-verdant-touch-applies-seed-of-life',
|
||||||
|
name: 'Verdant Touch Applies Seed of Life',
|
||||||
|
description: 'Verdant Touch also applies Seed of Life to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-verdant-touch-applies-barkskin',
|
||||||
|
name: 'Verdant Touch Applies 50% Barkskin',
|
||||||
|
description: 'Verdant Touch also applies Barkskin at 50% strength to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-seed-of-life-applies-barkskin',
|
||||||
|
name: 'Seed of Life Applies Barkskin',
|
||||||
|
description: 'Seed of Life also applies Barkskin to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-wild-growth-applies-seed-of-life',
|
||||||
|
name: 'Wild Growth Applies Seed of Life',
|
||||||
|
description: 'Wild Growth also applies Seed of Life to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-wild-growth-applies-barkskin',
|
||||||
|
name: 'Wild Growth Applies 50% Barkskin',
|
||||||
|
description: 'Wild Growth also applies Barkskin at 50% strength to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-barkskin-applies-seed-of-life',
|
||||||
|
name: 'Barkskin Applies Seed of Life',
|
||||||
|
description: 'Barkskin also applies Seed of Life to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
||||||
|
name: 'Barkskin Feeds HoTs',
|
||||||
|
description: 'While shielded by Barkskin, the target receives 25% more healing over time.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-purging-sap-applies-seed-of-life',
|
||||||
|
name: 'Purging Sap Applies Seed of Life',
|
||||||
|
description: 'Purging Sap also applies Seed of Life to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifebinder-purging-sap-applies-barkskin',
|
||||||
|
name: 'Purging Sap Applies Barkskin',
|
||||||
|
description: 'Purging Sap also applies Barkskin to the target.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const IWT2_RUNESAGE_SYNERGY_CHOICES: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>> = [
|
||||||
|
{
|
||||||
|
id: 'runesage-etched-mend-applies-mending-rune',
|
||||||
|
name: 'Etched Mend Applies Mending Rune',
|
||||||
|
description: 'Etched Mend also applies Mending Rune to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-etched-mend-applies-aegis-script',
|
||||||
|
name: 'Etched Mend Applies 50% Aegis Script',
|
||||||
|
description: 'Etched Mend also applies Aegis Script at 50% strength to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-mending-rune-applies-aegis-script',
|
||||||
|
name: 'Mending Rune Applies Aegis Script',
|
||||||
|
description: 'Mending Rune also applies Aegis Script to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-concordance-applies-mending-rune',
|
||||||
|
name: 'Concordance Applies Mending Rune',
|
||||||
|
description: 'Concordance also applies Mending Rune to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-concordance-applies-aegis-script',
|
||||||
|
name: 'Concordance Applies 50% Aegis Script',
|
||||||
|
description: 'Concordance also applies Aegis Script at 50% strength to affected allies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-aegis-script-applies-mending-rune',
|
||||||
|
name: 'Aegis Script Applies Mending Rune',
|
||||||
|
description: 'Aegis Script also applies Mending Rune to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
|
name: 'Aegis Script Reduces Damage',
|
||||||
|
description: 'While shielded by Aegis Script, the target takes 30% less damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-unravel-applies-mending-rune',
|
||||||
|
name: 'Unravel Applies Mending Rune',
|
||||||
|
description: 'Unravel also applies Mending Rune to the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runesage-unravel-applies-aegis-script',
|
||||||
|
name: 'Unravel Applies Aegis Script',
|
||||||
|
description: 'Unravel also applies Aegis Script to the target.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
||||||
return {
|
return {
|
||||||
id: ability.id,
|
id: ability.id,
|
||||||
@@ -49,11 +223,15 @@ export function iwt2AbilityToSpell(ability: Iwt2HealerAbility): Spell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
export function buildIwt2SelfBuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||||
return buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
const baseChoices = buildSelfSlotUpgradeChoices<Iwt2RoguelikeSelfBuffId>({
|
||||||
slots: IWT2_ROGUELIKE_SLOTS,
|
slots: IWT2_ROGUELIKE_SLOTS,
|
||||||
spells: abilities.map(iwt2AbilityToSpell),
|
spells: abilities.map(iwt2AbilityToSpell),
|
||||||
labelMode: 'ability',
|
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[]) {
|
export function buildIwt2OpponentDebuffChoices(abilities: Iwt2HealerAbility[]) {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { Iwt2BossEntityState } from '../sim'
|
||||||
|
|
||||||
|
export type BossRenderMotion = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
scaleX: number
|
||||||
|
scaleY: number
|
||||||
|
rotation: number
|
||||||
|
tint?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BossMotionProfile = {
|
||||||
|
idleFrequency: number
|
||||||
|
idleBob: number
|
||||||
|
moveFrequency: number
|
||||||
|
moveBob: number
|
||||||
|
impactScale: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE: BossMotionProfile = {
|
||||||
|
idleFrequency: 1.6,
|
||||||
|
idleBob: 2.1,
|
||||||
|
moveFrequency: 5.4,
|
||||||
|
moveBob: 3,
|
||||||
|
impactScale: 0.035,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOSS_PROFILES: Partial<Record<Iwt2BossEntityState['bossId'], BossMotionProfile>> = {
|
||||||
|
bulldrome: {
|
||||||
|
idleFrequency: 1.35,
|
||||||
|
idleBob: 2.3,
|
||||||
|
moveFrequency: 5.9,
|
||||||
|
moveBob: 3.2,
|
||||||
|
impactScale: 0.045,
|
||||||
|
},
|
||||||
|
'yian-kut-ku': {
|
||||||
|
idleFrequency: 2.2,
|
||||||
|
idleBob: 2.8,
|
||||||
|
moveFrequency: 6.4,
|
||||||
|
moveBob: 3.4,
|
||||||
|
impactScale: 0.032,
|
||||||
|
},
|
||||||
|
'obsidian-ram-golem': {
|
||||||
|
idleFrequency: 1.05,
|
||||||
|
idleBob: 1.6,
|
||||||
|
moveFrequency: 3.7,
|
||||||
|
moveBob: 2.5,
|
||||||
|
impactScale: 0.052,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const WARM_TINT = 0xffc88a
|
||||||
|
const IMPACT_TINT = 0xff9b5f
|
||||||
|
|
||||||
|
export function bossRenderMotion(entity: Iwt2BossEntityState, timeSeconds: number): BossRenderMotion {
|
||||||
|
const profile = BOSS_PROFILES[entity.bossId] ?? DEFAULT_PROFILE
|
||||||
|
const phase = String(entity.attackPhase)
|
||||||
|
const speed = Math.hypot(entity.velocity.x, entity.velocity.y)
|
||||||
|
const moving = speed > 8 || phase === 'relocating'
|
||||||
|
const facingX = entity.facing.x < -0.05 ? -1 : 1
|
||||||
|
|
||||||
|
const motion: BossRenderMotion = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActionPhase(phase, 'windup')) return windupMotion(motion, entity, phase, timeSeconds, facingX)
|
||||||
|
if (isBurstPhase(phase)) return burstMotion(motion, timeSeconds, facingX)
|
||||||
|
if (isActionPhase(phase, 'recover')) return recoverMotion(motion, profile, entity, timeSeconds)
|
||||||
|
if (moving) return movingMotion(motion, profile, entity, timeSeconds, facingX)
|
||||||
|
return idleMotion(motion, profile, timeSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleMotion(motion: BossRenderMotion, profile: BossMotionProfile, timeSeconds: number): BossRenderMotion {
|
||||||
|
const wave = Math.sin(timeSeconds * profile.idleFrequency * Math.PI * 2)
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
y: -Math.max(0, wave) * profile.idleBob,
|
||||||
|
scaleY: 1 + Math.max(0, wave) * 0.025,
|
||||||
|
scaleX: 1 - Math.max(0, wave) * 0.01,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function movingMotion(
|
||||||
|
motion: BossRenderMotion,
|
||||||
|
profile: BossMotionProfile,
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
facingX: number,
|
||||||
|
): BossRenderMotion {
|
||||||
|
const speedFactor = Math.min(1, Math.hypot(entity.velocity.x, entity.velocity.y) / 180)
|
||||||
|
const stride = Math.sin(timeSeconds * profile.moveFrequency * Math.PI * 2)
|
||||||
|
const lift = Math.abs(stride) * profile.moveBob
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
y: -lift,
|
||||||
|
scaleX: 1 + 0.012 * speedFactor,
|
||||||
|
scaleY: 1 - 0.009 * Math.abs(stride),
|
||||||
|
rotation: facingX * 0.025 * speedFactor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function windupMotion(
|
||||||
|
motion: BossRenderMotion,
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
phase: string,
|
||||||
|
timeSeconds: number,
|
||||||
|
facingX: number,
|
||||||
|
): BossRenderMotion {
|
||||||
|
const pulse = 0.5 + Math.sin(timeSeconds * Math.PI * 9) * 0.5
|
||||||
|
const isSlam = phase.includes('slam') || phase.includes('quake') || phase.includes('shatter')
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
x: -facingX * (isSlam ? 1.5 : 3),
|
||||||
|
y: isSlam ? -2 : 2,
|
||||||
|
scaleX: 1.035,
|
||||||
|
scaleY: 0.965,
|
||||||
|
rotation: -facingX * 0.02,
|
||||||
|
tint: pulse > 0.45 || entity.phaseSecondsRemaining < 0.18 ? WARM_TINT : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function burstMotion(motion: BossRenderMotion, timeSeconds: number, facingX: number): BossRenderMotion {
|
||||||
|
const shake = Math.sin(timeSeconds * Math.PI * 38) * 1.8
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
x: facingX * 3 + shake,
|
||||||
|
y: Math.cos(timeSeconds * Math.PI * 42) * 1.2,
|
||||||
|
scaleX: 1.045,
|
||||||
|
scaleY: 0.96,
|
||||||
|
rotation: facingX * 0.018,
|
||||||
|
tint: WARM_TINT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverMotion(
|
||||||
|
motion: BossRenderMotion,
|
||||||
|
profile: BossMotionProfile,
|
||||||
|
entity: Iwt2BossEntityState,
|
||||||
|
timeSeconds: number,
|
||||||
|
): BossRenderMotion {
|
||||||
|
const settle = Math.max(0, Math.min(1, entity.phaseSecondsRemaining / 0.45))
|
||||||
|
const impactPulse = Math.abs(Math.sin(timeSeconds * Math.PI * 11)) * settle
|
||||||
|
const pop = profile.impactScale * impactPulse
|
||||||
|
return {
|
||||||
|
...motion,
|
||||||
|
y: -2 * impactPulse,
|
||||||
|
scaleX: 1 + pop,
|
||||||
|
scaleY: 1 + pop * 0.45,
|
||||||
|
tint: impactPulse > 0.45 ? IMPACT_TINT : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionPhase(phase: string, suffix: string): boolean {
|
||||||
|
return phase.toLowerCase().includes(suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBurstPhase(phase: string): boolean {
|
||||||
|
const normalized = phase.toLowerCase()
|
||||||
|
return (
|
||||||
|
normalized.includes('charging')
|
||||||
|
|| normalized.includes('swooping')
|
||||||
|
|| normalized.includes('burrowing')
|
||||||
|
|| normalized.includes('ricocheting')
|
||||||
|
|| normalized.includes('flying')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import type { MovementVector } from '../../../../input'
|
import type { MovementVector } from '../../../../input'
|
||||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../../content/bosses'
|
||||||
import { IWT2_CLASS_METADATA } from '../../content/classes'
|
import { IWT2_CLASS_METADATA, type Iwt2PlayerClassId } from '../../content/classes'
|
||||||
|
import { bossRenderMotion } from '../bossAnimation'
|
||||||
import type {
|
import type {
|
||||||
|
Iwt2ArenaEvent,
|
||||||
Iwt2ArenaIndicator,
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaState,
|
Iwt2ArenaState,
|
||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
@@ -20,6 +22,11 @@ type SceneDeps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
|
type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState
|
||||||
|
type FloatingCombatTextAnchor = {
|
||||||
|
kind: DrawableEntity['kind']
|
||||||
|
position: Iwt2Vec2
|
||||||
|
radius: number
|
||||||
|
}
|
||||||
|
|
||||||
export class BulldromeArenaScene extends Phaser.Scene {
|
export class BulldromeArenaScene extends Phaser.Scene {
|
||||||
private deps: SceneDeps
|
private deps: SceneDeps
|
||||||
@@ -29,6 +36,13 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
private telegraphGraphics?: Phaser.GameObjects.Graphics
|
||||||
private labels = new Map<string, Phaser.GameObjects.Text>()
|
private labels = new Map<string, Phaser.GameObjects.Text>()
|
||||||
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
private bossSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private partySprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private projectileSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||||
|
private floatingCombatTexts = new Map<number, Phaser.GameObjects.Text>()
|
||||||
|
private castGlowEffects = new Set<Phaser.GameObjects.Graphics>()
|
||||||
|
private lastEntityAnchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
|
private lastFloatingEventId = 0
|
||||||
|
private lastStateTime = 0
|
||||||
private lastHudPublish = 0
|
private lastHudPublish = 0
|
||||||
|
|
||||||
constructor(deps: SceneDeps) {
|
constructor(deps: SceneDeps) {
|
||||||
@@ -44,6 +58,11 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
this.load.image(bossSpriteKey(metadata.id), metadata.spriteUrl)
|
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() {
|
create() {
|
||||||
@@ -66,6 +85,7 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
this.drawTelegraphs(state)
|
this.drawTelegraphs(state)
|
||||||
this.drawEntities(state)
|
this.drawEntities(state)
|
||||||
this.drawProjectiles(state)
|
this.drawProjectiles(state)
|
||||||
|
this.drawFloatingCombatTexts(state)
|
||||||
this.lastHudPublish += 1
|
this.lastHudPublish += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +119,9 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
graphics.clear()
|
graphics.clear()
|
||||||
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
for (const hazard of state.hazards) drawHazard(graphics, hazard)
|
||||||
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
|
const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, ...state.bosses]
|
||||||
const liveLabelIds = new Set<string>(entities.filter((entity) => entity.kind !== 'boss').map((entity) => entity.id))
|
const liveLabelIds = new Set<string>(entities.filter((entity) => shouldDrawLabel(entity)).map((entity) => entity.id))
|
||||||
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
|
const liveBossIds = new Set<string>(state.bosses.map((boss) => boss.id))
|
||||||
|
const livePartyIds = new Set<string>(state.party.map((member) => member.id))
|
||||||
|
|
||||||
for (const entity of entities.sort(entitySort)) {
|
for (const entity of entities.sort(entitySort)) {
|
||||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
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 (entity.kind === 'boss') {
|
||||||
if (this.textures.exists(bossSpriteKey(entity.bossId))) {
|
if (this.textures.exists(bossSpriteKey(entity.bossId))) {
|
||||||
drawBossUnderlay(bossUnderlay, entity, color, alpha)
|
drawBossUnderlay(bossUnderlay, entity, color, alpha)
|
||||||
this.drawBossSprite(entity, alpha)
|
this.drawBossSprite(entity, alpha, state.time)
|
||||||
} else {
|
} else {
|
||||||
drawBossFallback(graphics, entity, color, alpha)
|
drawBossFallback(graphics, entity, color, alpha)
|
||||||
}
|
}
|
||||||
@@ -134,16 +155,21 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
entity.position.y + entity.radius,
|
entity.position.y + entity.radius,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha)
|
|
||||||
graphics.lineStyle(entity.aiRole === 'player' ? 4 : 2, 0x0a0c10, 1)
|
graphics.lineStyle(entity.aiRole === 'player' ? 4 : 2, 0x0a0c10, 1)
|
||||||
if (entity.id === this.deps.getSelectedPartyId()) {
|
if (entity.id === this.deps.getSelectedPartyId()) {
|
||||||
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
graphics.lineStyle(4, 0xfff4a8, 0.95)
|
||||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8)
|
||||||
}
|
}
|
||||||
graphics.fillCircle(entity.position.x, entity.position.y, entity.radius)
|
if (this.textures.exists(classArenaSpriteKey(entity.classId))) {
|
||||||
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
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)
|
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) {
|
for (const [id, label] of this.labels) {
|
||||||
if (!liveLabelIds.has(id)) {
|
if (!liveLabelIds.has(id)) {
|
||||||
label.destroy()
|
label.destroy()
|
||||||
@@ -164,16 +197,40 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private drawProjectiles(state: Iwt2ArenaState) {
|
private drawProjectiles(state: Iwt2ArenaState) {
|
||||||
const graphics = this.entityGraphics!
|
const graphics = this.entityGraphics!
|
||||||
|
const liveProjectileIds = new Set(state.projectiles.map((projectile) => projectile.id))
|
||||||
for (const projectile of state.projectiles) {
|
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)
|
const color = Number.parseInt(projectile.color.slice(1), 16)
|
||||||
graphics.fillStyle(color, 0.95)
|
graphics.fillStyle(color, 0.95)
|
||||||
graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75)
|
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.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3)
|
||||||
graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius)
|
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 key = bossSpriteKey(entity.bossId)
|
||||||
const metadata = IWT2_BOSS_METADATA[entity.bossId]
|
const metadata = IWT2_BOSS_METADATA[entity.bossId]
|
||||||
let sprite = this.bossSprites.get(entity.id)
|
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 width = entity.radius * (metadata.spriteWidthScale ?? 3.35)
|
||||||
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
|
const height = entity.radius * (metadata.spriteHeightScale ?? 3.35)
|
||||||
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
|
const yOffset = entity.radius * (metadata.spriteYOffsetScale ?? 0)
|
||||||
|
const motion = bossRenderMotion(entity, timeSeconds)
|
||||||
|
if (motion.tint) {
|
||||||
|
sprite.setTint(motion.tint)
|
||||||
|
} else {
|
||||||
|
sprite.clearTint()
|
||||||
|
}
|
||||||
sprite
|
sprite
|
||||||
.setTexture(key)
|
.setTexture(key)
|
||||||
.setPosition(entity.position.x, entity.position.y + yOffset)
|
.setPosition(entity.position.x + motion.x, entity.position.y + yOffset + motion.y)
|
||||||
.setDisplaySize(width, height)
|
.setDisplaySize(width * motion.scaleX, height * motion.scaleY)
|
||||||
.setRotation(rotation)
|
.setRotation(rotation + motion.rotation)
|
||||||
.setFlipX(isSideView && entity.facing.x < -0.05)
|
.setFlipX(isSideView && entity.facing.x < -0.05)
|
||||||
.setAlpha(alpha)
|
.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) {
|
private drawLabel(entity: DrawableEntity) {
|
||||||
let label = this.labels.get(entity.id)
|
let label = this.labels.get(entity.id)
|
||||||
const icon = entityIcon(entity)
|
const icon = entity.kind === 'party' ? '!' : entityIcon(entity)
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = this.add.text(entity.position.x, entity.position.y, icon, {
|
label = this.add.text(entity.position.x, entity.position.y, icon, {
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -210,9 +290,8 @@ export class BulldromeArenaScene extends Phaser.Scene {
|
|||||||
}).setOrigin(0.5)
|
}).setOrigin(0.5)
|
||||||
this.labels.set(entity.id, label)
|
this.labels.set(entity.id, label)
|
||||||
}
|
}
|
||||||
const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0)
|
label.setText(icon)
|
||||||
label.setText(stunned ? '!' : icon)
|
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'party' ? entity.radius * 2.1 : 0))
|
||||||
label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0))
|
|
||||||
label.setAlpha(entity.health <= 0 ? 0.35 : 1)
|
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)
|
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 {
|
function entitySort(a: DrawableEntity, b: DrawableEntity): number {
|
||||||
@@ -268,6 +436,72 @@ function bossSpriteKey(bossId: Iwt2BossId): string {
|
|||||||
return `iwt2-boss-${bossId}`
|
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<string, FloatingCombatTextAnchor> {
|
||||||
|
const anchors = new Map<string, FloatingCombatTextAnchor>()
|
||||||
|
for (const entity of [...state.party, ...state.hostileAdds, ...state.bosses]) {
|
||||||
|
anchors.set(entity.id, {
|
||||||
|
kind: entity.kind,
|
||||||
|
position: { ...entity.position },
|
||||||
|
radius: entity.radius,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return anchors
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatingTextYOffset(anchor: FloatingCombatTextAnchor): number {
|
||||||
|
if (anchor.kind === 'boss') return anchor.radius * 1.45
|
||||||
|
if (anchor.kind === 'hostileAdd') return anchor.radius * 1.8
|
||||||
|
return anchor.radius * 2.85
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffHeadPosition(entity: Iwt2PartyEntityState): Iwt2Vec2 {
|
||||||
|
const facingSign = entity.facing.x < -0.05 ? -1 : 1
|
||||||
|
return {
|
||||||
|
x: entity.position.x + facingSign * entity.radius * 1.45,
|
||||||
|
y: entity.position.y - entity.radius * 2.15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPartyUnderlay(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
|
entity: Iwt2PartyEntityState,
|
||||||
|
color: string,
|
||||||
|
alpha: number,
|
||||||
|
) {
|
||||||
|
const colorValue = Number.parseInt(color.slice(1), 16)
|
||||||
|
graphics.fillStyle(0x050608, 0.38 * alpha)
|
||||||
|
graphics.fillEllipse(entity.position.x, entity.position.y + entity.radius * 0.8, entity.radius * 2.25, entity.radius * 0.72)
|
||||||
|
graphics.lineStyle(entity.aiRole === 'player' ? 3 : 2, colorValue, 0.58 * alpha)
|
||||||
|
graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius)
|
||||||
|
}
|
||||||
|
|
||||||
function drawBossUnderlay(
|
function drawBossUnderlay(
|
||||||
graphics: Phaser.GameObjects.Graphics,
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
entity: Iwt2BossEntityState,
|
entity: Iwt2BossEntityState,
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities'
|
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 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 = {
|
export type Iwt2InventoryItem = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
quantity: number
|
quantity: number
|
||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
|
itemLevel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue'
|
||||||
@@ -30,6 +54,7 @@ export type Iwt2CloudSlot = {
|
|||||||
character: Iwt2Character
|
character: Iwt2Character
|
||||||
inventory: Iwt2InventoryItem[]
|
inventory: Iwt2InventoryItem[]
|
||||||
collectionLog: Iwt2CollectionLog
|
collectionLog: Iwt2CollectionLog
|
||||||
|
gearProgress: Iwt2GearProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2BossPetAward = {
|
export type Iwt2BossPetAward = {
|
||||||
@@ -41,25 +66,42 @@ export type Iwt2BossPetAward = {
|
|||||||
quantityAfter: number
|
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 = {
|
export type Iwt2BossKillReward = {
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
|
dropAwarded: Iwt2BossDropAward
|
||||||
petAwarded: Iwt2BossPetAward | null
|
petAwarded: Iwt2BossPetAward | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2Save = {
|
export type Iwt2Save = {
|
||||||
version: 1
|
version: 2
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
character: Iwt2Character
|
character: Iwt2Character
|
||||||
inventory: Iwt2InventoryItem[]
|
inventory: Iwt2InventoryItem[]
|
||||||
collectionLog: Iwt2CollectionLog
|
collectionLog: Iwt2CollectionLog
|
||||||
|
gearProgress: Iwt2GearProgress
|
||||||
cloudSlot?: Iwt2CloudSlot
|
cloudSlot?: Iwt2CloudSlot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Iwt2OnlineSaveResult = {
|
||||||
|
save: Iwt2Save | null
|
||||||
|
}
|
||||||
|
|
||||||
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1'
|
||||||
|
|
||||||
export function createDefaultIwt2Save(): Iwt2Save {
|
export function createDefaultIwt2Save(): Iwt2Save {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 2,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: 'Healer',
|
name: 'Healer',
|
||||||
@@ -74,15 +116,16 @@ export function createDefaultIwt2Save(): Iwt2Save {
|
|||||||
bossPets: {},
|
bossPets: {},
|
||||||
dropsFound: {},
|
dropsFound: {},
|
||||||
},
|
},
|
||||||
|
gearProgress: createDefaultIwt2GearProgress(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSave(value: unknown): Iwt2Save {
|
function normalizeSave(value: unknown): Iwt2Save {
|
||||||
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
|
if (!value || typeof value !== 'object') return createDefaultIwt2Save()
|
||||||
const candidate = value as Partial<Iwt2Save>
|
const candidate = value as Partial<Omit<Iwt2Save, 'version'>> & { version?: number }
|
||||||
if (candidate.version !== 1) return createDefaultIwt2Save()
|
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 2,
|
||||||
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
|
updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(),
|
||||||
character: {
|
character: {
|
||||||
name: candidate.character?.name || 'Healer',
|
name: candidate.character?.name || 'Healer',
|
||||||
@@ -91,12 +134,13 @@ function normalizeSave(value: unknown): Iwt2Save {
|
|||||||
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
healerStyle: asIwt2HealerId(candidate.character?.healerStyle),
|
||||||
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette),
|
||||||
},
|
},
|
||||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
inventory: normalizeInventory(candidate.inventory),
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: candidate.collectionLog?.bossKills ?? {},
|
bossKills: candidate.collectionLog?.bossKills ?? {},
|
||||||
bossPets: candidate.collectionLog?.bossPets ?? {},
|
bossPets: candidate.collectionLog?.bossPets ?? {},
|
||||||
dropsFound: candidate.collectionLog?.dropsFound ?? {},
|
dropsFound: normalizeDropsFound(candidate.collectionLog?.dropsFound),
|
||||||
},
|
},
|
||||||
|
gearProgress: normalizeGearProgress(candidate.gearProgress),
|
||||||
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
cloudSlot: normalizeCloudSlot(candidate.cloudSlot),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +153,24 @@ export function loadIwt2Save(): Iwt2Save {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> {
|
||||||
|
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save')
|
||||||
|
return {
|
||||||
|
save: result.save ? normalizeSave(result.save) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeIwt2OnlineSave(save: Iwt2Save): Promise<Iwt2OnlineSaveResult> {
|
||||||
|
const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ save }),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
save: result.save ? normalizeSave(result.save) : normalizeSave(save),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function writeIwt2Save(save: Iwt2Save) {
|
export function writeIwt2Save(save: Iwt2Save) {
|
||||||
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({
|
||||||
...save,
|
...save,
|
||||||
@@ -116,32 +178,41 @@ export function writeIwt2Save(save: Iwt2Save) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKill(save: Iwt2Save, bossId: Iwt2BossId): Iwt2Save {
|
export function recordIwt2BossKill(
|
||||||
return recordIwt2BossKillReward(save, bossId).save
|
save: Iwt2Save,
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
options?: { difficultySlug?: string, experienceMultiplier?: number },
|
||||||
|
): Iwt2Save {
|
||||||
|
return recordIwt2BossKillReward(save, bossId, options).save
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iwt2BossKillReward {
|
export function recordIwt2BossKillReward(
|
||||||
const drop = iwt2BossMaterialRewardFor(bossId)
|
save: Iwt2Save,
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
options?: { difficultySlug?: string, experienceMultiplier?: number },
|
||||||
|
): Iwt2BossKillReward {
|
||||||
|
const drop = iwt2BossCoinRewardFor(bossId, options?.difficultySlug)
|
||||||
const pet = iwt2BossPetRewardFor(bossId)
|
const pet = iwt2BossPetRewardFor(bossId)
|
||||||
const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE
|
const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE
|
||||||
const previousPetQuantity = save.collectionLog.bossPets[pet.id] ?? 0
|
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 = {
|
const updatedSave: Iwt2Save = {
|
||||||
...save,
|
...save,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
character: {
|
character: {
|
||||||
...save.character,
|
...save.character,
|
||||||
experience: save.character.experience + 125,
|
experience: save.character.experience + experienceReward,
|
||||||
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + 125) / 500)),
|
level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + experienceReward) / 500)),
|
||||||
},
|
},
|
||||||
inventory: [
|
inventory: inventoryResult.inventory,
|
||||||
...save.inventory,
|
|
||||||
{
|
|
||||||
id: `${drop.id}-${Date.now()}`,
|
|
||||||
name: drop.name,
|
|
||||||
quantity: 1,
|
|
||||||
rarity: 'common',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
...save.collectionLog,
|
...save.collectionLog,
|
||||||
bossKills: {
|
bossKills: {
|
||||||
@@ -156,12 +227,22 @@ export function recordIwt2BossKillReward(save: Iwt2Save, bossId: Iwt2BossId): Iw
|
|||||||
: save.collectionLog.bossPets,
|
: save.collectionLog.bossPets,
|
||||||
dropsFound: {
|
dropsFound: {
|
||||||
...save.collectionLog.dropsFound,
|
...save.collectionLog.dropsFound,
|
||||||
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1,
|
[drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + quantity,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
save: updatedSave,
|
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
|
petAwarded: awardedPet
|
||||||
? {
|
? {
|
||||||
bossId,
|
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(
|
export function updateIwt2CharacterSettings(
|
||||||
save: Iwt2Save,
|
save: Iwt2Save,
|
||||||
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
|
settings: Partial<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
|
||||||
@@ -205,6 +381,7 @@ export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
|||||||
bossPets: { ...save.collectionLog.bossPets },
|
bossPets: { ...save.collectionLog.bossPets },
|
||||||
dropsFound: { ...save.collectionLog.dropsFound },
|
dropsFound: { ...save.collectionLog.dropsFound },
|
||||||
},
|
},
|
||||||
|
gearProgress: cloneGearProgress(save.gearProgress),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +398,7 @@ export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save {
|
|||||||
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
|
bossPets: { ...save.cloudSlot.collectionLog.bossPets },
|
||||||
dropsFound: { ...save.cloudSlot.collectionLog.dropsFound },
|
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),
|
healerStyle: asIwt2HealerId(candidate.character.healerStyle),
|
||||||
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette),
|
||||||
},
|
},
|
||||||
inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [],
|
inventory: normalizeInventory(candidate.inventory),
|
||||||
collectionLog: {
|
collectionLog: {
|
||||||
bossKills: candidate.collectionLog.bossKills ?? {},
|
bossKills: candidate.collectionLog.bossKills ?? {},
|
||||||
bossPets: candidate.collectionLog.bossPets ?? {},
|
bossPets: candidate.collectionLog.bossPets ?? {},
|
||||||
dropsFound: candidate.collectionLog.dropsFound ?? {},
|
dropsFound: normalizeDropsFound(candidate.collectionLog.dropsFound),
|
||||||
},
|
},
|
||||||
|
gearProgress: normalizeGearProgress(candidate.gearProgress),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
||||||
|
const defaults = createDefaultIwt2GearProgress()
|
||||||
|
if (!value || typeof value !== 'object') return defaults
|
||||||
|
const candidate = value as Partial<Record<Iwt2PlayerClassId, unknown>>
|
||||||
|
const next = createDefaultIwt2GearProgress()
|
||||||
|
for (const classId of IWT2_PARTY_ORDER) {
|
||||||
|
const rawClassProgress = candidate[classId]
|
||||||
|
if (!rawClassProgress || typeof rawClassProgress !== 'object') continue
|
||||||
|
const classProgress = rawClassProgress as {
|
||||||
|
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||||
|
infusionAbilityId?: unknown
|
||||||
|
}
|
||||||
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
|
next[classId].slots[slotId] = {
|
||||||
|
level: asGearLevel(classProgress.slots?.[slotId]?.level),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const infusionAbilityId = classProgress.infusionAbilityId
|
||||||
|
next[classId].infusionAbilityId = iwt2InfusionAbilitiesForClass(classId).some((ability) => ability.id === infusionAbilityId)
|
||||||
|
&& isIwt2InfusionUnlocked(next[classId])
|
||||||
|
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
||||||
|
return normalizeGearProgress(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asGearLevel(value: unknown): Iwt2GearLevel {
|
||||||
|
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
|
||||||
|
return level as Iwt2GearLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const byId = new Map<string, Iwt2InventoryItem>()
|
||||||
|
for (const rawItem of value) {
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') continue
|
||||||
|
const item = rawItem as Partial<Iwt2InventoryItem>
|
||||||
|
if (!item.id || !item.name) continue
|
||||||
|
const bossId = legacyBossIdForDropId(item.id)
|
||||||
|
const normalizedItem = bossId
|
||||||
|
? iwt2BossCoinRewardFor(bossId, 'initiate')
|
||||||
|
: {
|
||||||
|
id: item.id,
|
||||||
|
itemLevel: Math.max(1, Math.floor(item.itemLevel ?? 1)),
|
||||||
|
name: item.name,
|
||||||
|
rarity: asItemRarity(item.rarity),
|
||||||
|
}
|
||||||
|
const quantity = Math.max(1, Math.floor(item.quantity ?? 1))
|
||||||
|
const existing = byId.get(normalizedItem.id)
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity
|
||||||
|
} else {
|
||||||
|
byId.set(normalizedItem.id, {
|
||||||
|
id: normalizedItem.id,
|
||||||
|
itemLevel: normalizedItem.itemLevel,
|
||||||
|
name: normalizedItem.name,
|
||||||
|
quantity,
|
||||||
|
rarity: normalizedItem.rarity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDropsFound(value: unknown): Record<string, number> {
|
||||||
|
if (!value || typeof value !== 'object') return {}
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
for (const [rawId, rawQuantity] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
const bossId = legacyBossIdForDropId(rawId)
|
||||||
|
const id = bossId ? iwt2BossCoinRewardFor(bossId, 'initiate').id : rawId
|
||||||
|
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0))
|
||||||
|
if (quantity > 0) next[id] = (next[id] ?? 0) + quantity
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyBossIdForDropId(dropId: string): Iwt2BossId | undefined {
|
||||||
|
return (Object.entries(IWT2_LEGACY_BOSS_MATERIAL_REWARDS) as Array<[Iwt2BossId, { id: string }]>)
|
||||||
|
.find(([, legacy]) => dropId === legacy.id || dropId.startsWith(`${legacy.id}-`))
|
||||||
|
?.[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function asItemRarity(value: unknown): Iwt2InventoryItem['rarity'] {
|
||||||
|
return value === 'uncommon'
|
||||||
|
|| value === 'rare'
|
||||||
|
|| value === 'epic'
|
||||||
|
|| value === 'legendary'
|
||||||
|
|| value === 'common'
|
||||||
|
? value
|
||||||
|
: 'common'
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { castIwt2HealerAbility } from '../sim'
|
|||||||
import { PhaserArena } from '../render/PhaserArena'
|
import { PhaserArena } from '../render/PhaserArena'
|
||||||
import {
|
import {
|
||||||
recordIwt2BossKillReward,
|
recordIwt2BossKillReward,
|
||||||
|
type Iwt2BossDropAward,
|
||||||
type Iwt2BossPetAward,
|
type Iwt2BossPetAward,
|
||||||
type Iwt2Save,
|
type Iwt2Save,
|
||||||
} from '../save/iwt2Repository'
|
} from '../save/iwt2Repository'
|
||||||
@@ -25,7 +26,8 @@ import { BossHud } from '../components/BossHud'
|
|||||||
import { PartyFrames } from '../components/PartyFrames'
|
import { PartyFrames } from '../components/PartyFrames'
|
||||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
import { IWT2_CLASS_METADATA } from '../content/classes'
|
||||||
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
|
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 { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||||
import type {
|
import type {
|
||||||
Iwt2RoguelikeContentType,
|
Iwt2RoguelikeContentType,
|
||||||
@@ -33,25 +35,44 @@ import type {
|
|||||||
Iwt2RoguelikeSelfBuffId,
|
Iwt2RoguelikeSelfBuffId,
|
||||||
Iwt2RoguelikeVariant,
|
Iwt2RoguelikeVariant,
|
||||||
} from '../content/roguelike'
|
} 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 ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||||
type OverlayAction = 'primary' | 'menu'
|
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
||||||
type OverlayNavEntry = {
|
type OverlayNavEntry = {
|
||||||
action: OverlayAction
|
action: OverlayAction
|
||||||
row: number
|
row: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0 },
|
||||||
{ action: 'menu', row: 1 },
|
{ 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 = {
|
type BossArenaScreenProps = {
|
||||||
bossId: Iwt2BossId
|
bossId: Iwt2BossId
|
||||||
bossIds?: Iwt2BossId[]
|
bossIds?: Iwt2BossId[]
|
||||||
|
difficulty?: Iwt2Difficulty
|
||||||
modeLabel?: string
|
modeLabel?: string
|
||||||
save: Iwt2Save
|
save: Iwt2Save
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onPvpRequeue?: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
roguelikeRun?: {
|
roguelikeRun?: {
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
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 bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||||
|
const pveGearActive = roguelikeRun?.variant !== 'pvp'
|
||||||
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
||||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale))
|
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
|
||||||
|
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
|
||||||
|
const roguelikeStage = roguelikeRun?.stage
|
||||||
|
const roguelikeContentType = roguelikeRun?.contentType
|
||||||
|
const roguelikeBuffs = roguelikeRun?.buffs ?? EMPTY_ROGUELIKE_BUFFS
|
||||||
|
const roguelikeDamageScale = roguelikeRun
|
||||||
|
? roguelikeIncomingDamageScale(roguelikeRun.stage, roguelikeRun.contentType)
|
||||||
|
: 1
|
||||||
|
const experienceMultiplier = difficulty?.experienceMultiplier ?? 1
|
||||||
|
const difficultySlug = difficulty?.slug ?? 'initiate'
|
||||||
|
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
|
||||||
|
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
|
||||||
|
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
|
||||||
|
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
combinedBossHealthScale,
|
||||||
|
combinedDamageScale,
|
||||||
|
roguelikeBuffs,
|
||||||
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
|
pveGearActive ? save.gearProgress : undefined,
|
||||||
|
))
|
||||||
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||||
pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
|
pvpRoguelike
|
||||||
|
? createInitialIwt2ArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
combinedBossHealthScale,
|
||||||
|
combinedDamageScale,
|
||||||
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
|
)
|
||||||
|
: null
|
||||||
))
|
))
|
||||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||||
|
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
||||||
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
|
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
|
||||||
const stateRef = useRef(arenaState)
|
const stateRef = useRef(arenaState)
|
||||||
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
|
||||||
@@ -121,8 +173,25 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
const resetArena = useCallback(() => {
|
const resetArena = useCallback(() => {
|
||||||
const next = createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale)
|
const pressureState = createPressureState(roguelikeStage, roguelikeContentType)
|
||||||
const nextOpponentState = pvpRoguelike ? createInitialIwt2ArenaState(bossId, bossIds, bossHealthScale) : null
|
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()
|
recordedKillIdsRef.current = new Set()
|
||||||
abilityCooldownsRef.current = {}
|
abilityCooldownsRef.current = {}
|
||||||
lastHudSignatureRef.current = arenaHudSignature(next)
|
lastHudSignatureRef.current = arenaHudSignature(next)
|
||||||
@@ -131,9 +200,11 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
setArenaState(next)
|
setArenaState(next)
|
||||||
setOpponentArenaState(nextOpponentState)
|
setOpponentArenaState(nextOpponentState)
|
||||||
setAbilityCooldowns({})
|
setAbilityCooldowns({})
|
||||||
|
setDropAwards([])
|
||||||
|
setPetAwards([])
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [bossHealthScale, bossId, bossIds, pvpRoguelike])
|
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
|
||||||
|
|
||||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
@@ -141,12 +212,21 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const abilities = useMemo(
|
const abilities = useMemo(
|
||||||
() => applyRoguelikeModifiers(
|
() => {
|
||||||
abilitiesForHealer(save.character.healerStyle),
|
const baseAbilities = abilitiesForHealer(
|
||||||
roguelikeRun?.buffs ?? [],
|
save.character.healerStyle,
|
||||||
roguelikeRun?.debuffs ?? [],
|
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
|
||||||
),
|
)
|
||||||
[roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
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) => {
|
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||||
@@ -168,8 +248,9 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
|
|
||||||
const moveOverlaySelection = useCallback((action: string) => {
|
const moveOverlaySelection = useCallback((action: string) => {
|
||||||
setSelectedOverlayAction((current) => {
|
setSelectedOverlayAction((current) => {
|
||||||
const active = OVERLAY_NAV_ENTRIES.find((entry) => entry.action === current) ?? OVERLAY_NAV_ENTRIES[0]
|
const entries = overlayNavEntriesFor(statusRef.current, pvpRoguelike)
|
||||||
const candidates = OVERLAY_NAV_ENTRIES.filter((entry) => {
|
const active = entries.find((entry) => entry.action === current) ?? entries[0]
|
||||||
|
const candidates = entries.filter((entry) => {
|
||||||
if (entry.action === current) return false
|
if (entry.action === current) return false
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
if (action === 'navigateDown') 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))
|
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
||||||
return candidates[0]?.action ?? current
|
return candidates[0]?.action ?? current
|
||||||
})
|
})
|
||||||
}, [])
|
}, [pvpRoguelike])
|
||||||
|
|
||||||
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
|
||||||
|
if (overlayAction === 'requeue') {
|
||||||
|
onPvpRequeue?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (overlayAction === 'menu') {
|
if (overlayAction === 'menu') {
|
||||||
onBack()
|
onBack()
|
||||||
return
|
return
|
||||||
@@ -195,7 +280,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resetArena()
|
resetArena()
|
||||||
}, [onBack, resetArena, roguelikeRun])
|
}, [onBack, onPvpRequeue, resetArena, roguelikeRun])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (device === 'controller' && statusRef.current !== 'playing') {
|
if (device === 'controller' && statusRef.current !== 'playing') {
|
||||||
@@ -273,21 +358,33 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
if (newlyDefeatedBosses.length > 0) {
|
if (newlyDefeatedBosses.length > 0) {
|
||||||
const nextRecordedIds = new Set(recordedKillIdsRef.current)
|
const nextRecordedIds = new Set(recordedKillIdsRef.current)
|
||||||
let updatedSave = saveRef.current
|
let updatedSave = saveRef.current
|
||||||
|
const newDropAwards: Iwt2BossDropAward[] = []
|
||||||
const newPetAwards: Iwt2BossPetAward[] = []
|
const newPetAwards: Iwt2BossPetAward[] = []
|
||||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
nextRecordedIds.add(defeatedBoss.bossId)
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId)
|
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||||
|
difficultySlug,
|
||||||
|
experienceMultiplier,
|
||||||
|
})
|
||||||
updatedSave = reward.save
|
updatedSave = reward.save
|
||||||
|
newDropAwards.push(reward.dropAwarded)
|
||||||
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
|
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
|
||||||
}
|
}
|
||||||
recordedKillIdsRef.current = nextRecordedIds
|
recordedKillIdsRef.current = nextRecordedIds
|
||||||
saveRef.current = updatedSave
|
saveRef.current = updatedSave
|
||||||
|
if (newDropAwards.length > 0) setDropAwards((current) => [...current, ...newDropAwards])
|
||||||
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
|
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
|
||||||
onSaveUpdated(updatedSave)
|
onSaveUpdated(updatedSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
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)) {
|
} else if (next.party.every((member) => member.health <= 0)) {
|
||||||
showOverlay('defeat')
|
showOverlay('defeat')
|
||||||
}
|
}
|
||||||
@@ -305,7 +402,7 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
setAbilityCooldowns(abilityCooldownsRef.current)
|
setAbilityCooldowns(abilityCooldownsRef.current)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}, [onSaveUpdated, pvpRoguelike, showOverlay])
|
}, [difficultySlug, experienceMultiplier, onSaveUpdated, pvpRoguelike, roguelikeRun, showOverlay])
|
||||||
|
|
||||||
const targetBindings = directPartyTargeting
|
const targetBindings = directPartyTargeting
|
||||||
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
|
? 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 alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
|
||||||
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
|
||||||
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
|
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 bossTitle = formatBossEncounterTitle(arenaState.bosses)
|
||||||
const overlayPrimaryLabel = status === 'paused'
|
const overlayPrimaryLabel = status === 'paused'
|
||||||
? 'Resume'
|
? 'Resume'
|
||||||
: status === 'victory' && roguelikeRun
|
: status === 'victory' && roguelikeRun
|
||||||
? 'Choose Upgrade'
|
? 'Choose Upgrade'
|
||||||
|
: pvpRoguelike && status !== 'playing'
|
||||||
|
? 'Rematch'
|
||||||
: 'Restart'
|
: 'Restart'
|
||||||
const overlayTitle = status === 'victory'
|
const overlayTitle = status === 'victory'
|
||||||
? `${bossTitle} Down`
|
? `${bossTitle} Down`
|
||||||
@@ -463,9 +563,13 @@ export function BossArenaScreen({ bossId, bossIds, modeLabel, save, onBack, onSa
|
|||||||
</div>
|
</div>
|
||||||
{status === 'victory' && (
|
{status === 'victory' && (
|
||||||
<div className="iwt2-result-reward">
|
<div className="iwt2-result-reward">
|
||||||
<span>+{arenaState.bosses.length * 125} XP</span>
|
<span>+{victoryExperience} XP</span>
|
||||||
<span>{arenaState.bosses.length} carves</span>
|
|
||||||
<span>Log +{arenaState.bosses.length}</span>
|
<span>Log +{arenaState.bosses.length}</span>
|
||||||
|
{dropAwards.map((award) => (
|
||||||
|
<span key={`${award.dropId}:${award.quantityAfter}`}>
|
||||||
|
{award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
{petAwards.map((award) => (
|
{petAwards.map((award) => (
|
||||||
<span key={`${award.petId}:${award.quantityAfter}`}>
|
<span key={`${award.petId}:${award.quantityAfter}`}>
|
||||||
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
|
{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' : ''}`}
|
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
|
||||||
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('primary')}
|
onClick={() => activateOverlayAction('primary')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('primary')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{overlayPrimaryLabel}
|
{overlayPrimaryLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{pvpRoguelike && (
|
||||||
|
<button
|
||||||
|
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'requeue' ? 'game-selected' : ''}`}
|
||||||
|
data-game-selected={selectedOverlayAction === 'requeue' ? 'true' : undefined}
|
||||||
|
onClick={() => activateOverlayAction('requeue')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('requeue')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Requeue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
|
||||||
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
|
||||||
onClick={() => activateOverlayAction('menu')}
|
onClick={() => activateOverlayAction('menu')}
|
||||||
|
onPointerDown={() => setSelectedOverlayAction('menu')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Menu
|
Menu
|
||||||
@@ -518,7 +635,12 @@ function formatArenaTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function roguelikeBossHealthScale(stage: number): number {
|
function roguelikeBossHealthScale(stage: number): number {
|
||||||
return 0.5 + Math.max(0, stage - 1) * 0.1
|
return 1 + Math.max(0, stage - 1) * 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||||
|
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||||
|
return DEFAULT_OVERLAY_NAV_ENTRIES
|
||||||
}
|
}
|
||||||
|
|
||||||
function arenaHudSignature(state: Iwt2ArenaState): string {
|
function arenaHudSignature(state: Iwt2ArenaState): string {
|
||||||
@@ -566,12 +688,66 @@ function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
|
|||||||
return changed ? next : cooldowns
|
return changed ? next : cooldowns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createArenaState(
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
bossIds: Iwt2BossId[] | undefined,
|
||||||
|
bossHealthScale: number,
|
||||||
|
partyDamageTakenScale: number,
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
|
roguelikePressure: ReturnType<typeof createPressureState>,
|
||||||
|
gearProgress?: Iwt2Save['gearProgress'],
|
||||||
|
): Iwt2ArenaState {
|
||||||
|
const baseState = createInitialIwt2ArenaState(
|
||||||
|
bossId,
|
||||||
|
bossIds,
|
||||||
|
bossHealthScale,
|
||||||
|
partyDamageTakenScale,
|
||||||
|
roguelikePressure,
|
||||||
|
)
|
||||||
|
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
|
||||||
|
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
|
||||||
|
const shieldedHotHealingMultiplier = buffs.includes(IWT2_BARKSKIN_HOT_BONUS_BUFF_ID) ? 1.25 : undefined
|
||||||
|
if (shieldedDamageTakenMultiplier === undefined && shieldedHotHealingMultiplier === undefined) return state
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
party: state.party.map((member) => ({
|
||||||
|
...member,
|
||||||
|
shieldedDamageTakenMultiplier,
|
||||||
|
shieldedHotHealingMultiplier,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPressureState(
|
||||||
|
stage: number | undefined,
|
||||||
|
contentType: Iwt2RoguelikeContentType | undefined,
|
||||||
|
) {
|
||||||
|
return stage && contentType ? createRoguelikePressureState(stage, contentType) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function shieldedDamageTakenMultiplierForBuffs(buffs: Iwt2RoguelikeSelfBuffId[]) {
|
||||||
|
const multipliers: number[] = []
|
||||||
|
if (buffs.includes(IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.5)
|
||||||
|
if (buffs.includes(IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.7)
|
||||||
|
return multipliers.length > 0 ? Math.min(...multipliers) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function applyRoguelikeModifiers(
|
function applyRoguelikeModifiers(
|
||||||
abilities: Iwt2HealerAbility[],
|
abilities: Iwt2HealerAbility[],
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
debuffs: Iwt2RoguelikeOpponentDebuffId[],
|
||||||
): Iwt2HealerAbility[] {
|
): Iwt2HealerAbility[] {
|
||||||
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
if (buffs.length === 0 && debuffs.length === 0) return abilities
|
||||||
|
const abilityById = new Map(abilities.map((ability) => [ability.id, ability]))
|
||||||
|
const renewEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-renew'))
|
||||||
|
const sunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'))
|
||||||
|
const halfSunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'), 0.5)
|
||||||
|
const seedOfLifeEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-seed-of-life'))
|
||||||
|
const barkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'))
|
||||||
|
const halfBarkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'), 0.5)
|
||||||
|
const mendingRuneEffect = triggeredEffectFromAbility(abilityById.get('runesage-mending-rune'))
|
||||||
|
const aegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'))
|
||||||
|
const halfAegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'), 0.5)
|
||||||
return abilities.map((ability) => {
|
return abilities.map((ability) => {
|
||||||
const slot = String(ability.slot)
|
const slot = String(ability.slot)
|
||||||
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
|
||||||
@@ -579,16 +755,118 @@ function applyRoguelikeModifiers(
|
|||||||
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
|
||||||
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
|
||||||
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
|
||||||
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0) return ability
|
const triggeredEffects = iwt2TriggeredEffectsForAbility({
|
||||||
|
ability,
|
||||||
|
aegisScriptEffect,
|
||||||
|
barkskinEffect,
|
||||||
|
buffs,
|
||||||
|
halfAegisScriptEffect,
|
||||||
|
halfBarkskinEffect,
|
||||||
|
halfSunWardEffect,
|
||||||
|
mendingRuneEffect,
|
||||||
|
renewEffect,
|
||||||
|
seedOfLifeEffect,
|
||||||
|
sunWardEffect,
|
||||||
|
})
|
||||||
|
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0 && triggeredEffects.length === 0) return ability
|
||||||
return {
|
return {
|
||||||
...ability,
|
...ability,
|
||||||
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
|
||||||
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
|
||||||
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
|
||||||
|
triggeredEffects: triggeredEffects.length > 0 ? triggeredEffects : ability.triggeredEffects,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iwt2TriggeredEffectsForAbility({
|
||||||
|
ability,
|
||||||
|
aegisScriptEffect,
|
||||||
|
barkskinEffect,
|
||||||
|
buffs,
|
||||||
|
halfAegisScriptEffect,
|
||||||
|
halfBarkskinEffect,
|
||||||
|
halfSunWardEffect,
|
||||||
|
mendingRuneEffect,
|
||||||
|
renewEffect,
|
||||||
|
seedOfLifeEffect,
|
||||||
|
sunWardEffect,
|
||||||
|
}: {
|
||||||
|
ability: Iwt2HealerAbility
|
||||||
|
aegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
barkskinEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
|
halfAegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
halfBarkskinEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
halfSunWardEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
mendingRuneEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
renewEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
seedOfLifeEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
sunWardEffect: Iwt2TriggeredAbilityEffect | undefined
|
||||||
|
}): Iwt2TriggeredAbilityEffect[] {
|
||||||
|
const effects = [...ability.triggeredEffects ?? []]
|
||||||
|
const addEffect = (effect: Iwt2TriggeredAbilityEffect | undefined) => {
|
||||||
|
if (effect && !effects.some((existing) => existing.id === effect.id && existing.power === effect.power)) {
|
||||||
|
effects.push(effect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ability.id === 'dawnweaver-mend') {
|
||||||
|
if (buffs.includes('dawnweaver-mend-applies-renew')) addEffect(renewEffect)
|
||||||
|
if (buffs.includes('dawnweaver-mend-applies-sun-ward')) addEffect(sunWardEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-renew') {
|
||||||
|
if (buffs.includes('dawnweaver-renew-applies-sun-ward')) addEffect(sunWardEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-radiance') {
|
||||||
|
if (buffs.includes('dawnweaver-radiance-applies-renew')) addEffect(renewEffect)
|
||||||
|
if (buffs.includes('dawnweaver-radiance-applies-sun-ward')) addEffect(halfSunWardEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-sun-ward') {
|
||||||
|
if (buffs.includes('dawnweaver-sun-ward-applies-renew')) addEffect(renewEffect)
|
||||||
|
} else if (ability.id === 'dawnweaver-purify') {
|
||||||
|
if (buffs.includes('dawnweaver-purify-applies-renew')) addEffect(renewEffect)
|
||||||
|
if (buffs.includes('dawnweaver-purify-applies-sun-ward')) addEffect(sunWardEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-verdant-touch') {
|
||||||
|
if (buffs.includes('lifebinder-verdant-touch-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
if (buffs.includes('lifebinder-verdant-touch-applies-barkskin')) addEffect(halfBarkskinEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-seed-of-life') {
|
||||||
|
if (buffs.includes('lifebinder-seed-of-life-applies-barkskin')) addEffect(barkskinEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-wild-growth') {
|
||||||
|
if (buffs.includes('lifebinder-wild-growth-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
if (buffs.includes('lifebinder-wild-growth-applies-barkskin')) addEffect(halfBarkskinEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-barkskin') {
|
||||||
|
if (buffs.includes('lifebinder-barkskin-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
} else if (ability.id === 'lifebinder-purging-sap') {
|
||||||
|
if (buffs.includes('lifebinder-purging-sap-applies-seed-of-life')) addEffect(seedOfLifeEffect)
|
||||||
|
if (buffs.includes('lifebinder-purging-sap-applies-barkskin')) addEffect(barkskinEffect)
|
||||||
|
} else if (ability.id === 'runesage-etched-mend') {
|
||||||
|
if (buffs.includes('runesage-etched-mend-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
if (buffs.includes('runesage-etched-mend-applies-aegis-script')) addEffect(halfAegisScriptEffect)
|
||||||
|
} else if (ability.id === 'runesage-mending-rune') {
|
||||||
|
if (buffs.includes('runesage-mending-rune-applies-aegis-script')) addEffect(aegisScriptEffect)
|
||||||
|
} else if (ability.id === 'runesage-concordance') {
|
||||||
|
if (buffs.includes('runesage-concordance-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
if (buffs.includes('runesage-concordance-applies-aegis-script')) addEffect(halfAegisScriptEffect)
|
||||||
|
} else if (ability.id === 'runesage-aegis-script') {
|
||||||
|
if (buffs.includes('runesage-aegis-script-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
} else if (ability.id === 'runesage-unravel') {
|
||||||
|
if (buffs.includes('runesage-unravel-applies-mending-rune')) addEffect(mendingRuneEffect)
|
||||||
|
if (buffs.includes('runesage-unravel-applies-aegis-script')) addEffect(aegisScriptEffect)
|
||||||
|
}
|
||||||
|
return effects
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggeredEffectFromAbility(
|
||||||
|
ability: Iwt2HealerAbility | undefined,
|
||||||
|
strength = 1,
|
||||||
|
): Iwt2TriggeredAbilityEffect | undefined {
|
||||||
|
if (!ability) return undefined
|
||||||
|
return {
|
||||||
|
effectType: ability.effectType,
|
||||||
|
id: ability.id,
|
||||||
|
kind: ability.kind,
|
||||||
|
name: ability.name,
|
||||||
|
power: Math.max(1, Math.round(ability.power * strength)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function countStacks(items: readonly string[], id: string) {
|
function countStacks(items: readonly string[], id: string) {
|
||||||
return items.filter((item) => item === id).length
|
return items.filter((item) => item === id).length
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
Iwt2PartyEntityId,
|
Iwt2PartyEntityId,
|
||||||
Iwt2PartyEntityState,
|
Iwt2PartyEntityState,
|
||||||
Iwt2ProjectileEntityState,
|
Iwt2ProjectileEntityState,
|
||||||
|
Iwt2RoguelikePressureState,
|
||||||
Iwt2StatusState,
|
Iwt2StatusState,
|
||||||
Iwt2Vec2,
|
Iwt2Vec2,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -22,6 +23,7 @@ import { separateCircles } from './collision'
|
|||||||
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
import { canPartyMemberHitTarget, tickPartyMember } from './partyAi'
|
||||||
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards'
|
||||||
import { applyPartyDamageInShape } from './mechanics'
|
import { applyPartyDamageInShape } from './mechanics'
|
||||||
|
import { tickRoguelikePressure } from './roguelikePressure'
|
||||||
import {
|
import {
|
||||||
addVec2,
|
addVec2,
|
||||||
clampVec2ToArena,
|
clampVec2ToArena,
|
||||||
@@ -62,6 +64,8 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossId: Iwt2BossId = 'bulldrome',
|
bossId: Iwt2BossId = 'bulldrome',
|
||||||
bossIds?: Iwt2BossId[],
|
bossIds?: Iwt2BossId[],
|
||||||
bossHealthScale = 1,
|
bossHealthScale = 1,
|
||||||
|
partyDamageTakenScale = 1,
|
||||||
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
||||||
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
|
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
|
||||||
@@ -69,11 +73,12 @@ export function createInitialIwt2ArenaState(
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
time: 0,
|
time: 0,
|
||||||
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||||
party: INITIAL_PARTY.map(createPartyMember),
|
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
|
||||||
projectiles: [],
|
projectiles: [],
|
||||||
hostileAdds: [],
|
hostileAdds: [],
|
||||||
hazards: [],
|
hazards: [],
|
||||||
indicators: [],
|
indicators: [],
|
||||||
|
roguelikePressure,
|
||||||
boss: bosses[0],
|
boss: bosses[0],
|
||||||
bosses,
|
bosses,
|
||||||
nextEventId: 1,
|
nextEventId: 1,
|
||||||
@@ -216,8 +221,13 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
party: projectileResult.party,
|
party: projectileResult.party,
|
||||||
time: separatedState.time,
|
time: separatedState.time,
|
||||||
})
|
})
|
||||||
|
const pressureResult = tickRoguelikePressure({
|
||||||
|
party: hazardResult.party,
|
||||||
|
pressure: separatedState.roguelikePressure,
|
||||||
|
time: separatedState.time,
|
||||||
|
})
|
||||||
const damageResult = applyPartyAttacks(
|
const damageResult = applyPartyAttacks(
|
||||||
hazardResult.party,
|
pressureResult.party,
|
||||||
projectileResult.bosses,
|
projectileResult.bosses,
|
||||||
projectileResult.hostileAdds,
|
projectileResult.hostileAdds,
|
||||||
separatedState.time,
|
separatedState.time,
|
||||||
@@ -234,7 +244,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
finalCollisionState,
|
finalCollisionState,
|
||||||
)
|
)
|
||||||
const nextEvents = assignEventIds(
|
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,
|
state.nextEventId,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -242,6 +252,7 @@ export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt:
|
|||||||
boss: getPrimaryBoss(damageResult.bosses),
|
boss: getPrimaryBoss(damageResult.bosses),
|
||||||
bosses: damageResult.bosses,
|
bosses: damageResult.bosses,
|
||||||
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)],
|
||||||
|
roguelikePressure: pressureResult.pressure,
|
||||||
party: finalParty,
|
party: finalParty,
|
||||||
hostileAdds: damageResult.hostileAdds,
|
hostileAdds: damageResult.hostileAdds,
|
||||||
hazards: hazardResult.hazards,
|
hazards: hazardResult.hazards,
|
||||||
@@ -270,7 +281,10 @@ function tickPartyHotEffects(
|
|||||||
let nextTickInSeconds = effect.nextTickInSeconds - dt
|
let nextTickInSeconds = effect.nextTickInSeconds - dt
|
||||||
if (member.health > 0 && nextTickInSeconds <= 0) {
|
if (member.health > 0 && nextTickInSeconds <= 0) {
|
||||||
const before = health
|
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
|
const healed = health - before
|
||||||
if (healed > 0) {
|
if (healed > 0) {
|
||||||
events.push({
|
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 metadata = IWT2_CLASS_METADATA[initial.classId]
|
||||||
|
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
|
||||||
return {
|
return {
|
||||||
id: initial.id,
|
id: initial.id,
|
||||||
kind: 'party',
|
kind: 'party',
|
||||||
@@ -321,8 +336,16 @@ function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState {
|
|||||||
velocity: { x: 0, y: 0 },
|
velocity: { x: 0, y: 0 },
|
||||||
facing: { x: 1, y: 0 },
|
facing: { x: 1, y: 0 },
|
||||||
radius: metadata.radius,
|
radius: metadata.radius,
|
||||||
|
moveSpeed: metadata.moveSpeed,
|
||||||
|
attackRange: metadata.attackRange,
|
||||||
|
attackDamage: metadata.attackDamage,
|
||||||
|
attackCooldown: metadata.attackCooldown,
|
||||||
|
castTime: metadata.castTime,
|
||||||
|
projectileSpeed: metadata.projectileSpeed,
|
||||||
health: metadata.maxHealth,
|
health: metadata.maxHealth,
|
||||||
maxHealth: metadata.maxHealth,
|
maxHealth: metadata.maxHealth,
|
||||||
|
damageTakenScale: safeDamageTakenScale,
|
||||||
|
stunTakenScale: 1,
|
||||||
shield: 0,
|
shield: 0,
|
||||||
mana: initial.classId === 'healer' ? 100 : 0,
|
mana: initial.classId === 'healer' ? 100 : 0,
|
||||||
maxMana: 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
|
let position = member.position
|
||||||
for (const boss of state.bosses) {
|
for (const boss of state.bosses) {
|
||||||
if (boss.health <= 0) continue
|
if (boss.health <= 0) continue
|
||||||
|
const overlapBeforeSeparation = circleOverlapAmount({ position, radius: member.radius }, boss)
|
||||||
const separated = separateCircles(
|
const separated = separateCircles(
|
||||||
{ position, radius: member.radius },
|
{ position, radius: member.radius },
|
||||||
{ position: boss.position, radius: boss.radius },
|
{ position: boss.position, radius: boss.radius },
|
||||||
state.bounds,
|
state.bounds,
|
||||||
)
|
)
|
||||||
position = wallClearance(separated, member, state) <= 2
|
const overlapAfterSeparation = circleOverlapAmount({ position: separated, radius: member.radius }, boss)
|
||||||
|| circleOverlapAmount({ position: separated, radius: member.radius }, boss) > 0.25
|
position = overlapBeforeSeparation > 0.25
|
||||||
|
&& (wallClearance(separated, member, state) <= 2 || overlapAfterSeparation > 0.25)
|
||||||
? separateFromBossTowardCenter(member, boss, state)
|
? separateFromBossTowardCenter(member, boss, state)
|
||||||
: separated
|
: separated
|
||||||
}
|
}
|
||||||
@@ -802,7 +827,7 @@ function applyPartyAttacks(
|
|||||||
if (!target) return member
|
if (!target) return member
|
||||||
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
|
if (!canPartyMemberHitTarget(member, target.position, target.radius)) return member
|
||||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
const metadata = IWT2_CLASS_METADATA[member.classId]
|
||||||
if (metadata.projectileSpeed > 0) {
|
if (member.projectileSpeed > 0) {
|
||||||
if (!member.attackReady) return member
|
if (!member.attackReady) return member
|
||||||
const direction = normalizeVec2(subtractVec2(target.position, member.position))
|
const direction = normalizeVec2(subtractVec2(target.position, member.position))
|
||||||
projectiles.push({
|
projectiles.push({
|
||||||
@@ -810,29 +835,29 @@ function applyPartyAttacks(
|
|||||||
sourceId: member.id,
|
sourceId: member.id,
|
||||||
owner: 'party',
|
owner: 'party',
|
||||||
classId: member.classId,
|
classId: member.classId,
|
||||||
projectileKind: member.classId === 'mage' ? 'magic' : 'arrow',
|
projectileKind: member.classId === 'mage' ? 'fireball' : 'arrow',
|
||||||
color: metadata.accentColor,
|
color: metadata.accentColor,
|
||||||
position: {
|
position: {
|
||||||
x: member.position.x + direction.x * (member.radius + 4),
|
x: member.position.x + direction.x * (member.radius + 4),
|
||||||
y: member.position.y + direction.y * (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,
|
radius: member.classId === 'mage' ? 7 : 4,
|
||||||
damage: metadata.attackDamage,
|
damage: member.attackDamage,
|
||||||
remainingSeconds: 1.2,
|
remainingSeconds: 1.2,
|
||||||
})
|
})
|
||||||
projectileId += 1
|
projectileId += 1
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
attackCooldownRemaining: metadata.attackCooldown,
|
attackCooldownRemaining: member.attackCooldown,
|
||||||
attackReady: false,
|
attackReady: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (member.attackCooldownRemaining > 0) return member
|
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
|
let defeated = target.health - damage <= 0
|
||||||
if (target.kind === 'boss') {
|
if (target.kind === 'boss') {
|
||||||
const damageResult = applyDamageToBoss(target, metadata.attackDamage)
|
const damageResult = applyDamageToBoss(target, member.attackDamage)
|
||||||
damage = damageResult.healthDamage
|
damage = damageResult.healthDamage
|
||||||
defeated = damageResult.boss.health <= 0
|
defeated = damageResult.boss.health <= 0
|
||||||
nextBosses = nextBosses.map((boss) => boss.id === target.id
|
nextBosses = nextBosses.map((boss) => boss.id === target.id
|
||||||
@@ -862,13 +887,13 @@ function applyPartyAttacks(
|
|||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
damageDone: member.damageDone + damage,
|
damageDone: member.damageDone + damage,
|
||||||
attackCooldownRemaining: metadata.attackCooldown,
|
attackCooldownRemaining: member.attackCooldown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
damageDone: member.damageDone + damage,
|
damageDone: member.damageDone + damage,
|
||||||
attackCooldownRemaining: metadata.attackCooldown,
|
attackCooldownRemaining: member.attackCooldown,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
Iwt2PartyEntityState,
|
Iwt2PartyEntityState,
|
||||||
|
Iwt2RoguelikePressureState,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
|
export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd'
|
||||||
@@ -58,8 +59,10 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossId?: Iwt2BossId,
|
bossId?: Iwt2BossId,
|
||||||
bossIds?: Iwt2BossId[],
|
bossIds?: Iwt2BossId[],
|
||||||
bossHealthScale?: number,
|
bossHealthScale?: number,
|
||||||
|
partyDamageTakenScale?: number,
|
||||||
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
): Iwt2ArenaState {
|
): 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 {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { tickTobiKadachi } from './tobiKadachiAi'
|
|||||||
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
|
import { tickVenomOrchidHydra } from './venomOrchidHydraAi'
|
||||||
import { tickYianKutKu } from './yianKutKuAi'
|
import { tickYianKutKu } from './yianKutKuAi'
|
||||||
import {
|
import {
|
||||||
|
applyPartyDamageToMember,
|
||||||
applyPartyDamageInShape,
|
applyPartyDamageInShape,
|
||||||
createArenaEvent,
|
createArenaEvent,
|
||||||
createCircleIndicator,
|
createCircleIndicator,
|
||||||
@@ -281,14 +282,19 @@ function maybeApplyMelee(
|
|||||||
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
|
if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) {
|
||||||
return { boss, party, events: [] }
|
return { boss, party, events: [] }
|
||||||
}
|
}
|
||||||
|
let appliedDamage = 0
|
||||||
|
let nextTargetHealth = target.health
|
||||||
const nextParty = party.map((member) => {
|
const nextParty = party.map((member) => {
|
||||||
if (member.id !== target.id) return 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 = [
|
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))
|
events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id))
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -37,22 +37,22 @@ const CINDERBACK_PHASE = {
|
|||||||
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
|
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
|
||||||
|
|
||||||
const CINDERBACK_TUNING = {
|
const CINDERBACK_TUNING = {
|
||||||
armorSlamDamage: 20,
|
armorSlamDamage: 44,
|
||||||
armorSlamRadius: 118,
|
armorSlamRadius: 118,
|
||||||
armorSlamRecover: 1.05,
|
armorSlamRecover: 1.05,
|
||||||
armorSlamStunSeconds: 0.7,
|
armorSlamStunSeconds: 0.7,
|
||||||
armorSlamWindup: 0.62,
|
armorSlamWindup: 0.62,
|
||||||
centerDisengageSpeed: 152,
|
centerDisengageSpeed: 152,
|
||||||
firePuddleDamage: 5,
|
firePuddleDamage: 15,
|
||||||
firePuddleRadius: 34,
|
firePuddleRadius: 34,
|
||||||
firePuddleSeconds: 6.4,
|
firePuddleSeconds: 6.4,
|
||||||
lavaTrailInterval: 0.26,
|
lavaTrailInterval: 0.26,
|
||||||
meleeCooldown: 1.12,
|
meleeCooldown: 1.12,
|
||||||
meleeDamage: 5,
|
meleeDamage: 11,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
moveSpeed: 104,
|
moveSpeed: 104,
|
||||||
ricochetCooldown: 7.2,
|
ricochetCooldown: 7.2,
|
||||||
ricochetDamage: 18,
|
ricochetDamage: 40,
|
||||||
ricochetDuration: 3.1,
|
ricochetDuration: 3.1,
|
||||||
ricochetMaxBounces: 4,
|
ricochetMaxBounces: 4,
|
||||||
ricochetSpeed: 470,
|
ricochetSpeed: 470,
|
||||||
|
|||||||
@@ -44,26 +44,26 @@ const CRYSTAL_BAT_TUNING = {
|
|||||||
clusterRadius: 84,
|
clusterRadius: 84,
|
||||||
hoverDistance: 138,
|
hoverDistance: 138,
|
||||||
meleeCooldown: 1.18,
|
meleeCooldown: 1.18,
|
||||||
meleeDamage: 4,
|
meleeDamage: 10,
|
||||||
meleeRange: 54,
|
meleeRange: 54,
|
||||||
moveSpeed: 138,
|
moveSpeed: 138,
|
||||||
relocateCooldownFloor: 1.2,
|
relocateCooldownFloor: 1.2,
|
||||||
relocateSeconds: 1.25,
|
relocateSeconds: 1.25,
|
||||||
relocateSpeed: 184,
|
relocateSpeed: 184,
|
||||||
shardCooldown: 5.8,
|
shardCooldown: 5.8,
|
||||||
shardDamage: 13,
|
shardDamage: 34,
|
||||||
shardLaneWidth: 30,
|
shardLaneWidth: 30,
|
||||||
shardRecover: 0.58,
|
shardRecover: 0.58,
|
||||||
shardStunSeconds: 0.35,
|
shardStunSeconds: 0.35,
|
||||||
shardWindup: 0.82,
|
shardWindup: 0.82,
|
||||||
sonicCooldown: 6.4,
|
sonicCooldown: 6.4,
|
||||||
sonicDamage: 10,
|
sonicDamage: 30,
|
||||||
sonicRecover: 0.42,
|
sonicRecover: 0.42,
|
||||||
sonicRingRadius: 58,
|
sonicRingRadius: 58,
|
||||||
sonicStunSeconds: 0.32,
|
sonicStunSeconds: 0.32,
|
||||||
sonicWindup: 0.78,
|
sonicWindup: 0.78,
|
||||||
swoopCooldown: 4.9,
|
swoopCooldown: 4.9,
|
||||||
swoopDamage: 17,
|
swoopDamage: 42,
|
||||||
swoopLaneWidth: 44,
|
swoopLaneWidth: 44,
|
||||||
swoopRecover: 0.62,
|
swoopRecover: 0.62,
|
||||||
swoopSpeed: 430,
|
swoopSpeed: 430,
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ type EmberMantisAttackPhase =
|
|||||||
const EMBER_MANTIS = {
|
const EMBER_MANTIS = {
|
||||||
accentColor: '#ffb13b',
|
accentColor: '#ffb13b',
|
||||||
crossSlashCooldown: 7.5,
|
crossSlashCooldown: 7.5,
|
||||||
crossSlashDamage: 20,
|
crossSlashDamage: 44,
|
||||||
crossSlashStunSeconds: 0.45,
|
crossSlashStunSeconds: 0.45,
|
||||||
crossSlashWindup: 0.82,
|
crossSlashWindup: 0.82,
|
||||||
duelistRange: 118,
|
duelistRange: 118,
|
||||||
lineSlashCooldown: 3.4,
|
lineSlashCooldown: 3.4,
|
||||||
lineSlashDamage: 15,
|
lineSlashDamage: 36,
|
||||||
lineSlashStunSeconds: 0.25,
|
lineSlashStunSeconds: 0.25,
|
||||||
lineSlashWindup: 0.48,
|
lineSlashWindup: 0.48,
|
||||||
meleeCooldown: 0.8,
|
meleeCooldown: 0.8,
|
||||||
meleeDamage: 7,
|
meleeDamage: 12,
|
||||||
meleeRange: 52,
|
meleeRange: 52,
|
||||||
moveSpeed: 168,
|
moveSpeed: 168,
|
||||||
recoverSeconds: 0.38,
|
recoverSeconds: 0.38,
|
||||||
|
|||||||
@@ -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<TState extends Pick<Iwt2ArenaState, 'party'>>(state: TState, gearProgress: Iwt2GearProgress): TState {
|
||||||
|
const party = state.party.map((member) => applyMemberGearStats(member, gearProgress))
|
||||||
|
return applyIwt2InfusionLoadout({
|
||||||
|
...state,
|
||||||
|
party,
|
||||||
|
}, gearProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyIwt2PveGearToHealerAbilities(
|
||||||
|
abilities: Iwt2HealerAbility[],
|
||||||
|
gearProgress: Iwt2GearProgress,
|
||||||
|
): Iwt2HealerAbility[] {
|
||||||
|
const healer = gearProgress.healer
|
||||||
|
const healingLevel = healer.slots.weapon.level
|
||||||
|
const cooldownLevel = healer.slots.helmet.level
|
||||||
|
if (healingLevel <= 0 && cooldownLevel <= 0) return abilities
|
||||||
|
return abilities.map((ability) => ({
|
||||||
|
...ability,
|
||||||
|
cooldownSeconds: roundOne(ability.cooldownSeconds * (1 - cooldownLevel * PER_LEVEL_COOLDOWN)),
|
||||||
|
power: Math.max(1, Math.round(ability.power * (1 + healingLevel * PER_LEVEL_OUTPUT))),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMemberGearStats(
|
||||||
|
member: Iwt2PartyEntityState,
|
||||||
|
gearProgress: Iwt2GearProgress,
|
||||||
|
): Iwt2PartyEntityState {
|
||||||
|
const classProgress = gearProgress[member.classId]
|
||||||
|
let next = { ...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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Iwt2HealerAbility } from '../content/healerAbilities'
|
import type { Iwt2HealerAbility, Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
|
||||||
import type {
|
import type {
|
||||||
Iwt2ArenaEvent,
|
Iwt2ArenaEvent,
|
||||||
Iwt2ArenaState,
|
Iwt2ArenaState,
|
||||||
@@ -31,6 +31,8 @@ export function castIwt2HealerAbility<TState extends Iwt2ArenaState>(
|
|||||||
|
|
||||||
const events: Iwt2ArenaEvent[] = []
|
const events: Iwt2ArenaEvent[] = []
|
||||||
let eventId = state.nextEventId
|
let eventId = state.nextEventId
|
||||||
|
events.push(createArenaEvent(eventId, state.time, 'healerSpellCast', HEALER_ID, targetIds[0]))
|
||||||
|
eventId += 1
|
||||||
const targetIdSet = new Set(targetIds)
|
const targetIdSet = new Set(targetIds)
|
||||||
const nextParty = state.party.map((member) => {
|
const nextParty = state.party.map((member) => {
|
||||||
const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana
|
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 {
|
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') {
|
if (ability.kind === 'hot') {
|
||||||
return {
|
return {
|
||||||
...healMember(member, ability.power),
|
...healMember(member, ability.power),
|
||||||
@@ -120,6 +128,37 @@ function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerA
|
|||||||
return healMember(member, ability.power)
|
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 {
|
function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState {
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type HollowcrownAttackPhase = Iwt2BossEntityState['attackPhase'] | HollowcrownSp
|
|||||||
|
|
||||||
const HOLLOWCROWN_TUNING = {
|
const HOLLOWCROWN_TUNING = {
|
||||||
antlerCooldown: 7.4,
|
antlerCooldown: 7.4,
|
||||||
antlerDamage: 17,
|
antlerDamage: 42,
|
||||||
antlerLaneLength: 440,
|
antlerLaneLength: 440,
|
||||||
antlerLaneOffset: 44,
|
antlerLaneOffset: 44,
|
||||||
antlerLineWidth: 30,
|
antlerLineWidth: 30,
|
||||||
@@ -52,22 +52,22 @@ const HOLLOWCROWN_TUNING = {
|
|||||||
antlerStunSeconds: 0.48,
|
antlerStunSeconds: 0.48,
|
||||||
antlerWindup: 0.82,
|
antlerWindup: 0.82,
|
||||||
dashCooldown: 5.2,
|
dashCooldown: 5.2,
|
||||||
dashDamage: 13,
|
dashDamage: 34,
|
||||||
dashLength: 330,
|
dashLength: 330,
|
||||||
dashRecover: 0.42,
|
dashRecover: 0.42,
|
||||||
dashSpeed: 430,
|
dashSpeed: 430,
|
||||||
dashStunSeconds: 0.32,
|
dashStunSeconds: 0.32,
|
||||||
dashTrailDamage: 9,
|
dashTrailDamage: 20,
|
||||||
dashTrailSeconds: 0.5,
|
dashTrailSeconds: 0.5,
|
||||||
dashTrailWidth: 40,
|
dashTrailWidth: 40,
|
||||||
hoofCooldown: 6.2,
|
hoofCooldown: 6.2,
|
||||||
hoofDamage: 12,
|
hoofDamage: 34,
|
||||||
hoofRadius: 46,
|
hoofRadius: 46,
|
||||||
hoofRecover: 0.46,
|
hoofRecover: 0.46,
|
||||||
hoofStunSeconds: 0.34,
|
hoofStunSeconds: 0.34,
|
||||||
hoofWindup: 0.74,
|
hoofWindup: 0.74,
|
||||||
meleeCooldown: 1.25,
|
meleeCooldown: 1.25,
|
||||||
meleeDamage: 5,
|
meleeDamage: 11,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
moveSpeed: 116,
|
moveSpeed: 116,
|
||||||
relocateSeconds: 1.25,
|
relocateSeconds: 1.25,
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export {
|
|||||||
castIwt2HealerAbility,
|
castIwt2HealerAbility,
|
||||||
} from './healing'
|
} from './healing'
|
||||||
|
|
||||||
|
export {
|
||||||
|
applyIwt2InfusionLoadout,
|
||||||
|
selectedInfusionForMember,
|
||||||
|
} from './infusionLoadout'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
addVec2,
|
addVec2,
|
||||||
clamp,
|
clamp,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbility } from '../content/infusionAbilities'
|
||||||
|
import type { Iwt2GearProgress } from '../content/gear'
|
||||||
|
import type { Iwt2ArenaState, Iwt2PartyEntityState } from './types'
|
||||||
|
|
||||||
|
export function applyIwt2InfusionLoadout<TState extends Pick<Iwt2ArenaState, 'party'>>(
|
||||||
|
state: TState,
|
||||||
|
gearProgress: Iwt2GearProgress,
|
||||||
|
): TState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
party: state.party.map((member) => {
|
||||||
|
const ability = selectedInfusionForMember(member, gearProgress)
|
||||||
|
return ability ? applyInfusionToMember(member, ability) : member
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedInfusionForMember(
|
||||||
|
member: Pick<Iwt2PartyEntityState, 'classId'>,
|
||||||
|
gearProgress: Iwt2GearProgress,
|
||||||
|
): Iwt2InfusionAbility | null {
|
||||||
|
const abilityId = gearProgress[member.classId].infusionAbilityId
|
||||||
|
if (!abilityId) return null
|
||||||
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
|
return ability?.classId === member.classId ? ability : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInfusionToMember(
|
||||||
|
member: Iwt2PartyEntityState,
|
||||||
|
ability: Iwt2InfusionAbility,
|
||||||
|
): Iwt2PartyEntityState {
|
||||||
|
const baseMember = {
|
||||||
|
...member,
|
||||||
|
infusionAbilityId: ability.id,
|
||||||
|
}
|
||||||
|
if (ability.effectKey === 'unbreakable') {
|
||||||
|
return {
|
||||||
|
...baseMember,
|
||||||
|
stunTakenScale: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseMember
|
||||||
|
}
|
||||||
@@ -127,25 +127,22 @@ export function applyPartyDamageInShape(
|
|||||||
const events: Iwt2ArenaEvent[] = []
|
const events: Iwt2ArenaEvent[] = []
|
||||||
const excluded = new Set(effect.excludedEntityIds ?? [])
|
const excluded = new Set(effect.excludedEntityIds ?? [])
|
||||||
const hitEntityIds: Iwt2EntityId[] = []
|
const hitEntityIds: Iwt2EntityId[] = []
|
||||||
const stunSeconds = effect.stunSeconds ?? 0
|
|
||||||
const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds
|
|
||||||
const nextParty = party.map((member) => {
|
const nextParty = party.map((member) => {
|
||||||
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
|
if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) {
|
||||||
return member
|
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)
|
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(
|
events.push(createArenaEvent(
|
||||||
0,
|
0,
|
||||||
effect.time,
|
effect.time,
|
||||||
effect.damageEventType ?? 'partyDamaged',
|
effect.damageEventType ?? 'partyDamaged',
|
||||||
effect.sourceId,
|
effect.sourceId,
|
||||||
member.id,
|
member.id,
|
||||||
effect.damage,
|
damageResult.damage,
|
||||||
))
|
))
|
||||||
if (stunSeconds > 0 || knockdownSeconds > 0) {
|
if (stunSeconds > 0 || knockdownSeconds > 0) {
|
||||||
events.push(createArenaEvent(
|
events.push(createArenaEvent(
|
||||||
@@ -157,14 +154,12 @@ export function applyPartyDamageInShape(
|
|||||||
Math.max(stunSeconds, knockdownSeconds),
|
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))
|
events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...member,
|
...damageResult.member,
|
||||||
health: nextHealth,
|
|
||||||
shield: nextShield,
|
|
||||||
status: {
|
status: {
|
||||||
...member.status,
|
...member.status,
|
||||||
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
|
stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds),
|
||||||
@@ -176,6 +171,29 @@ export function applyPartyDamageInShape(
|
|||||||
return { party: nextParty, hitEntityIds, events }
|
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 {
|
export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean {
|
||||||
if (shape.kind === 'lane') {
|
if (shape.kind === 'lane') {
|
||||||
return circleIntersectsSegment(
|
return circleIntersectsSegment(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const OBSIDIAN_RAM = {
|
|||||||
armorCracksBeforeShatter: 3,
|
armorCracksBeforeShatter: 3,
|
||||||
centerDisengageSpeed: 118,
|
centerDisengageSpeed: 118,
|
||||||
chargeCooldown: 4.4,
|
chargeCooldown: 4.4,
|
||||||
chargeDamage: 8,
|
chargeDamage: 24,
|
||||||
chargeLength: 340,
|
chargeLength: 340,
|
||||||
chargeRecover: 0.5,
|
chargeRecover: 0.5,
|
||||||
chargeSpeed: 390,
|
chargeSpeed: 390,
|
||||||
@@ -55,11 +55,11 @@ const OBSIDIAN_RAM = {
|
|||||||
chargeWidth: 54,
|
chargeWidth: 54,
|
||||||
chargeWindup: 0.68,
|
chargeWindup: 0.68,
|
||||||
fractureLaneCount: 5,
|
fractureLaneCount: 5,
|
||||||
fractureLaneDamage: 6,
|
fractureLaneDamage: 20,
|
||||||
fractureLaneLength: 300,
|
fractureLaneLength: 300,
|
||||||
fractureLaneWidth: 30,
|
fractureLaneWidth: 30,
|
||||||
meleeCooldown: 1.3,
|
meleeCooldown: 1.3,
|
||||||
meleeDamage: 2,
|
meleeDamage: 8,
|
||||||
meleeRange: 62,
|
meleeRange: 62,
|
||||||
moveSpeed: 82,
|
moveSpeed: 82,
|
||||||
quakeCooldown: 7.4,
|
quakeCooldown: 7.4,
|
||||||
@@ -67,7 +67,7 @@ const OBSIDIAN_RAM = {
|
|||||||
quakeRecover: 0.48,
|
quakeRecover: 0.48,
|
||||||
quakeStunSeconds: 0.35,
|
quakeStunSeconds: 0.35,
|
||||||
quakeWindup: 0.78,
|
quakeWindup: 0.78,
|
||||||
slabDamage: 10,
|
slabDamage: 22,
|
||||||
slabDuration: 5.8,
|
slabDuration: 5.8,
|
||||||
slabRadius: 42,
|
slabRadius: 42,
|
||||||
slabSlowRadius: 46,
|
slabSlowRadius: 46,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { IWT2_CLASS_METADATA } from '../content/classes'
|
|
||||||
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
|
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
|
||||||
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
||||||
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
|
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
|
||||||
@@ -34,7 +33,6 @@ export function tickPartyMember(
|
|||||||
inputMove: Iwt2Vec2,
|
inputMove: Iwt2Vec2,
|
||||||
dt: number,
|
dt: number,
|
||||||
): Iwt2PartyEntityState {
|
): Iwt2PartyEntityState {
|
||||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
|
||||||
const status = {
|
const status = {
|
||||||
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
|
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
|
||||||
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
|
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
|
||||||
@@ -52,7 +50,7 @@ export function tickPartyMember(
|
|||||||
|
|
||||||
if (member.aiRole === 'player') {
|
if (member.aiRole === 'player') {
|
||||||
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
|
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(
|
const position = clampVec2ToArena(
|
||||||
{
|
{
|
||||||
x: member.position.x + velocity.x * dt,
|
x: member.position.x + velocity.x * dt,
|
||||||
@@ -82,7 +80,7 @@ export function tickPartyMember(
|
|||||||
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
||||||
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
||||||
? castSecondsRemaining
|
? castSecondsRemaining
|
||||||
: metadata.castTime
|
: member.castTime
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
velocity: { x: 0, y: 0 },
|
velocity: { x: 0, y: 0 },
|
||||||
@@ -96,7 +94,7 @@ export function tickPartyMember(
|
|||||||
|
|
||||||
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
||||||
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
|
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 distanceToDesired = distanceVec2(member.position, desired)
|
||||||
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
|
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
|
||||||
const position = movingToDesired
|
const position = movingToDesired
|
||||||
@@ -229,9 +227,8 @@ function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
|
|||||||
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
|
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
|
||||||
if (member.health <= 0) return false
|
if (member.health <= 0) return false
|
||||||
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
||||||
const metadata = IWT2_CLASS_METADATA[member.classId]
|
if (member.attackDamage <= 0) return false
|
||||||
if (metadata.attackDamage <= 0) return false
|
return distanceVec2(member.position, targetPosition) <= member.attackRange + member.radius + targetRadius
|
||||||
return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius + targetRadius
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPartyDesiredPosition(
|
function getPartyDesiredPosition(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ type ShardCone = {
|
|||||||
|
|
||||||
const RIMEBASTION_BALANCE = {
|
const RIMEBASTION_BALANCE = {
|
||||||
iceWallCooldown: 7.2,
|
iceWallCooldown: 7.2,
|
||||||
iceWallDamage: 6,
|
iceWallDamage: 18,
|
||||||
iceWallStunSeconds: 0.25,
|
iceWallStunSeconds: 0.25,
|
||||||
iceWallWindup: 1,
|
iceWallWindup: 1,
|
||||||
iceCircleCount: 2,
|
iceCircleCount: 2,
|
||||||
@@ -57,16 +57,16 @@ const RIMEBASTION_BALANCE = {
|
|||||||
iceWallLaneCount: 3,
|
iceWallLaneCount: 3,
|
||||||
iceWallWidth: 22,
|
iceWallWidth: 22,
|
||||||
meleeCooldown: 1.45,
|
meleeCooldown: 1.45,
|
||||||
meleeDamage: 4,
|
meleeDamage: 10,
|
||||||
meleeRange: 66,
|
meleeRange: 66,
|
||||||
moveSpeed: 64,
|
moveSpeed: 64,
|
||||||
relocateCooldownFloor: 1.3,
|
relocateCooldownFloor: 1.3,
|
||||||
relocateSpeed: 112,
|
relocateSpeed: 112,
|
||||||
relocateSeconds: 1.6,
|
relocateSeconds: 1.6,
|
||||||
shatterConeAngleRadians: Math.PI * 0.34,
|
shatterConeAngleRadians: Math.PI * 0.34,
|
||||||
shatterConeDamage: 9,
|
shatterConeDamage: 30,
|
||||||
shatterConeRange: 158,
|
shatterConeRange: 158,
|
||||||
shatterLaneDamage: 11,
|
shatterLaneDamage: 34,
|
||||||
shatterLaneWidth: 34,
|
shatterLaneWidth: 34,
|
||||||
shatterRecover: 0.52,
|
shatterRecover: 0.52,
|
||||||
shatterStunSeconds: 0.4,
|
shatterStunSeconds: 0.4,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -50,20 +50,20 @@ const SANDGLASS = {
|
|||||||
burrowWindup: 0.48,
|
burrowWindup: 0.48,
|
||||||
disengageSeconds: 0.72,
|
disengageSeconds: 0.72,
|
||||||
disengageSpeed: 148,
|
disengageSpeed: 148,
|
||||||
eruptionDamage: 15,
|
eruptionDamage: 70,
|
||||||
eruptionRadius: 50,
|
eruptionRadius: 50,
|
||||||
eruptionRecover: 0.52,
|
eruptionRecover: 0.52,
|
||||||
eruptionStunSeconds: 0.42,
|
eruptionStunSeconds: 0.42,
|
||||||
eruptionWindup: 0.76,
|
eruptionWindup: 0.76,
|
||||||
hourglassCooldown: 8.1,
|
hourglassCooldown: 8.1,
|
||||||
hourglassDamage: 6,
|
hourglassDamage: 35,
|
||||||
hourglassHazardSeconds: 2.2,
|
hourglassHazardSeconds: 2.2,
|
||||||
hourglassRadius: 66,
|
hourglassRadius: 66,
|
||||||
hourglassRecover: 0.48,
|
hourglassRecover: 0.48,
|
||||||
hourglassSafeSeconds: 1.05,
|
hourglassSafeSeconds: 1.05,
|
||||||
hourglassTickSeconds: 0.5,
|
hourglassTickSeconds: 0.5,
|
||||||
meleeCooldown: 1.25,
|
meleeCooldown: 1.25,
|
||||||
meleeDamage: 5,
|
meleeDamage: 22,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
moveSpeed: 94,
|
moveSpeed: 94,
|
||||||
wallMargin: 70,
|
wallMargin: 70,
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ type StormcoilPhase = typeof STORMCOIL_PHASE[keyof typeof STORMCOIL_PHASE]
|
|||||||
const STORMCOIL_TUNING = {
|
const STORMCOIL_TUNING = {
|
||||||
arcAngleRadians: Math.PI * 0.34,
|
arcAngleRadians: Math.PI * 0.34,
|
||||||
arcCooldown: 5.6,
|
arcCooldown: 5.6,
|
||||||
arcDamage: 6,
|
arcDamage: 22,
|
||||||
arcInnerRadius: 78,
|
arcInnerRadius: 78,
|
||||||
arcOuterRadius: 196,
|
arcOuterRadius: 196,
|
||||||
arcRecover: 0.46,
|
arcRecover: 0.46,
|
||||||
arcStunSeconds: 0.24,
|
arcStunSeconds: 0.24,
|
||||||
arcWindup: 0.74,
|
arcWindup: 0.74,
|
||||||
chainCooldown: 7.4,
|
chainCooldown: 7.4,
|
||||||
chainDamage: 5,
|
chainDamage: 18,
|
||||||
chainLinkRadius: 150,
|
chainLinkRadius: 150,
|
||||||
chainMaxLinks: 7,
|
chainMaxLinks: 7,
|
||||||
chainRecover: 0.5,
|
chainRecover: 0.5,
|
||||||
@@ -57,14 +57,14 @@ const STORMCOIL_TUNING = {
|
|||||||
chainWindup: 0.68,
|
chainWindup: 0.68,
|
||||||
disengageSpeed: 168,
|
disengageSpeed: 168,
|
||||||
donutCooldown: 6.2,
|
donutCooldown: 6.2,
|
||||||
donutDamage: 7,
|
donutDamage: 24,
|
||||||
donutInnerRadius: 64,
|
donutInnerRadius: 64,
|
||||||
donutOuterRadius: 156,
|
donutOuterRadius: 156,
|
||||||
donutRecover: 0.48,
|
donutRecover: 0.48,
|
||||||
donutStunSeconds: 0.44,
|
donutStunSeconds: 0.44,
|
||||||
donutWindup: 0.82,
|
donutWindup: 0.82,
|
||||||
meleeCooldown: 1.12,
|
meleeCooldown: 1.12,
|
||||||
meleeDamage: 2,
|
meleeDamage: 8,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
moveSpeed: 124,
|
moveSpeed: 124,
|
||||||
wallContactLimit: 0.72,
|
wallContactLimit: 0.72,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import type { Iwt2PlayerClassId } from '../content/classes'
|
import type { Iwt2PlayerClassId } from '../content/classes'
|
||||||
|
import type { Iwt2InfusionAbilityId } from '../content/infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2Vec2 = {
|
export type Iwt2Vec2 = {
|
||||||
x: number
|
x: number
|
||||||
@@ -47,13 +48,24 @@ export type Iwt2PartyEntityState = {
|
|||||||
id: Iwt2PartyEntityId
|
id: Iwt2PartyEntityId
|
||||||
kind: 'party'
|
kind: 'party'
|
||||||
classId: Iwt2PlayerClassId
|
classId: Iwt2PlayerClassId
|
||||||
|
infusionAbilityId?: Iwt2InfusionAbilityId
|
||||||
aiRole: Iwt2PartyAiRole
|
aiRole: Iwt2PartyAiRole
|
||||||
position: Iwt2Vec2
|
position: Iwt2Vec2
|
||||||
velocity: Iwt2Vec2
|
velocity: Iwt2Vec2
|
||||||
facing: Iwt2Vec2
|
facing: Iwt2Vec2
|
||||||
radius: number
|
radius: number
|
||||||
|
moveSpeed: number
|
||||||
|
attackRange: number
|
||||||
|
attackDamage: number
|
||||||
|
attackCooldown: number
|
||||||
|
castTime: number
|
||||||
|
projectileSpeed: number
|
||||||
health: number
|
health: number
|
||||||
maxHealth: number
|
maxHealth: number
|
||||||
|
damageTakenScale: number
|
||||||
|
stunTakenScale: number
|
||||||
|
shieldedDamageTakenMultiplier?: number
|
||||||
|
shieldedHotHealingMultiplier?: number
|
||||||
shield: number
|
shield: number
|
||||||
mana: number
|
mana: number
|
||||||
maxMana: number
|
maxMana: number
|
||||||
@@ -270,6 +282,7 @@ export type Iwt2GroundHazardState = {
|
|||||||
|
|
||||||
export type Iwt2ArenaEventType =
|
export type Iwt2ArenaEventType =
|
||||||
| 'bossDamaged'
|
| 'bossDamaged'
|
||||||
|
| 'healerSpellCast'
|
||||||
| 'partyHealed'
|
| 'partyHealed'
|
||||||
| 'partyDamaged'
|
| 'partyDamaged'
|
||||||
| 'partyStunned'
|
| 'partyStunned'
|
||||||
@@ -287,6 +300,16 @@ export type Iwt2ArenaEvent = {
|
|||||||
value?: number
|
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 Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover'
|
||||||
|
|
||||||
export type Iwt2ArenaIndicatorBase = {
|
export type Iwt2ArenaIndicatorBase = {
|
||||||
@@ -354,6 +377,7 @@ export type Iwt2ArenaState = {
|
|||||||
boss: Iwt2BossEntityState
|
boss: Iwt2BossEntityState
|
||||||
bosses: Iwt2BossEntityState[]
|
bosses: Iwt2BossEntityState[]
|
||||||
indicators: Iwt2ArenaIndicator[]
|
indicators: Iwt2ArenaIndicator[]
|
||||||
|
roguelikePressure?: Iwt2RoguelikePressureState
|
||||||
nextEventId: number
|
nextEventId: number
|
||||||
nextProjectileId: number
|
nextProjectileId: number
|
||||||
nextAddId: number
|
nextAddId: number
|
||||||
|
|||||||
@@ -42,29 +42,29 @@ const VENOM_ORCHID_HYDRA_PHASE = {
|
|||||||
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
|
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
|
||||||
|
|
||||||
const VENOM_ORCHID_HYDRA = {
|
const VENOM_ORCHID_HYDRA = {
|
||||||
bloomBurstDamage: 7,
|
bloomBurstDamage: 18,
|
||||||
bloomCooldown: 8.6,
|
bloomCooldown: 8.6,
|
||||||
bloomPodCount: 3,
|
bloomPodCount: 3,
|
||||||
bloomPodDamage: 3,
|
bloomPodDamage: 10,
|
||||||
bloomPodRadius: 30,
|
bloomPodRadius: 30,
|
||||||
bloomPodSeconds: 1.4,
|
bloomPodSeconds: 1.4,
|
||||||
bloomRecover: 0.5,
|
bloomRecover: 0.5,
|
||||||
bloomWindup: 0.9,
|
bloomWindup: 0.9,
|
||||||
coneAngleRadians: Math.PI * 0.34,
|
coneAngleRadians: Math.PI * 0.34,
|
||||||
coneCooldown: 5.7,
|
coneCooldown: 5.7,
|
||||||
coneDamage: 10,
|
coneDamage: 28,
|
||||||
coneRange: 178,
|
coneRange: 178,
|
||||||
coneRecover: 0.62,
|
coneRecover: 0.62,
|
||||||
coneStaggerWindup: 0.48,
|
coneStaggerWindup: 0.48,
|
||||||
coneStunSeconds: 0.18,
|
coneStunSeconds: 0.18,
|
||||||
headOffset: 22,
|
headOffset: 22,
|
||||||
meleeCooldown: 1.35,
|
meleeCooldown: 1.35,
|
||||||
meleeDamage: 4,
|
meleeDamage: 10,
|
||||||
meleeRange: 58,
|
meleeRange: 58,
|
||||||
moveSpeed: 58,
|
moveSpeed: 58,
|
||||||
relocateSpeed: 114,
|
relocateSpeed: 114,
|
||||||
relocateSeconds: 1.45,
|
relocateSeconds: 1.45,
|
||||||
rootLaneDamage: 8,
|
rootLaneDamage: 22,
|
||||||
rootLaneLengthInset: 36,
|
rootLaneLengthInset: 36,
|
||||||
rootLaneWidth: 28,
|
rootLaneWidth: 28,
|
||||||
rootRecover: 0.58,
|
rootRecover: 0.58,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type Iwt2YianTickResult = {
|
|||||||
const BIRD_COUNT = 3
|
const BIRD_COUNT = 3
|
||||||
const BIRD_MELEE_RANGE = 24
|
const BIRD_MELEE_RANGE = 24
|
||||||
const BIRD_MELEE_COOLDOWN = 1.15
|
const BIRD_MELEE_COOLDOWN = 1.15
|
||||||
const BIRD_FLIGHT_DAMAGE = 10
|
const BIRD_FLIGHT_DAMAGE = 26
|
||||||
const YIAN_FIREBALL_BOUNCES = 8
|
const YIAN_FIREBALL_BOUNCES = 8
|
||||||
const YIAN_SAFE_WALL_MARGIN = 118
|
const YIAN_SAFE_WALL_MARGIN = 118
|
||||||
const YIAN_CENTER_CAST_DISTANCE = 36
|
const YIAN_CENTER_CAST_DISTANCE = 36
|
||||||
|
|||||||
@@ -91,5 +91,6 @@ export function coinDropQuantity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function roguelikeCoinItemLevel(stage) {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 987 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 753 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 874 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 894 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |