diff --git a/AGENTS.md b/AGENTS.md index b66b210..369c5df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ - IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state. - IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement. - IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements. +- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically. - First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds. - IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme. diff --git a/IWantToHeal-Thor-v1.1.15.apk b/IWantToHeal-Thor-v1.1.15.apk new file mode 100644 index 0000000..a4ec170 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.15.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 2df01bb..18fe586 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 93 - versionName "1.1.14" + versionCode 94 + versionName "1.1.15" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/docs/ui-mockups/iwt2-mechanics-foundation.png b/docs/ui-mockups/iwt2-mechanics-foundation.png new file mode 100644 index 0000000..47a959b Binary files /dev/null and b/docs/ui-mockups/iwt2-mechanics-foundation.png differ diff --git a/docs/ui-mockups/iwt2-menu-main.png b/docs/ui-mockups/iwt2-menu-main.png new file mode 100644 index 0000000..bf92fa4 Binary files /dev/null and b/docs/ui-mockups/iwt2-menu-main.png differ diff --git a/docs/ui-mockups/iwt2-settings-main.png b/docs/ui-mockups/iwt2-settings-main.png new file mode 100644 index 0000000..967d4f4 Binary files /dev/null and b/docs/ui-mockups/iwt2-settings-main.png differ diff --git a/docs/ui-mockups/iwt2-thor-bottom.png b/docs/ui-mockups/iwt2-thor-bottom.png new file mode 100644 index 0000000..9018741 Binary files /dev/null and b/docs/ui-mockups/iwt2-thor-bottom.png differ diff --git a/docs/ui-mockups/iwt2-thor-main.png b/docs/ui-mockups/iwt2-thor-main.png new file mode 100644 index 0000000..e52ae63 Binary files /dev/null and b/docs/ui-mockups/iwt2-thor-main.png differ diff --git a/package-lock.json b/package-lock.json index f1c0715..42a95e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@capacitor/android": "^8.4.0", "@capacitor/core": "^8.4.0", + "phaser": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -2030,6 +2031,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2969,6 +2976,15 @@ "dev": true, "license": "MIT" }, + "node_modules/phaser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz", + "integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 1666642..bc8413c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "@capacitor/android": "^8.4.0", "@capacitor/core": "^8.4.0", + "phaser": "^4.2.0", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/server/admin.mjs b/server/admin.mjs index bff7d54..7fc645b 100644 --- a/server/admin.mjs +++ b/server/admin.mjs @@ -1,4 +1,4 @@ -import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { extname, resolve, sep } from 'node:path' @@ -10,6 +10,7 @@ const port = Number(process.env.ADMIN_PORT ?? 4174) const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url)) const distPath = fileURLToPath(new URL('../dist', import.meta.url)) const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url)) +const iwt2BalanceOverridesPath = fileURLToPath(new URL('../src/modes/iwt2/content/balanceOverrides.ts', import.meta.url)) const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url)) const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url)) const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url)) @@ -248,6 +249,89 @@ function saveDungeonImage(database, dungeonId, payload) { return imageUrl } +function readIwt2BalanceOverrides() { + if (!existsSync(iwt2BalanceOverridesPath)) return { bosses: {} } + const source = readFileSync(iwt2BalanceOverridesPath, 'utf8') + const exportIndex = source.indexOf('IWT2_BALANCE_OVERRIDES') + const objectStart = source.indexOf('{', exportIndex) + const objectEnd = source.lastIndexOf('}') + if (exportIndex < 0 || objectStart < 0 || objectEnd <= objectStart) return { bosses: {} } + try { + const parsed = JSON.parse(source.slice(objectStart, objectEnd + 1)) + return sanitizeIwt2BalanceOverrides(parsed) + } catch { + return { bosses: {} } + } +} + +function sanitizeIwt2BalanceOverrides(value) { + const overrides = { bosses: {} } + const bosses = value && typeof value === 'object' && value.bosses && typeof value.bosses === 'object' + ? value.bosses + : {} + for (const bossId of ['bulldrome', 'yian-kut-ku']) { + const boss = bosses[bossId] + if (!boss || typeof boss !== 'object') continue + const nextBoss = {} + if (Number.isFinite(Number(boss.maxHealth))) nextBoss.maxHealth = Math.max(1, Math.round(Number(boss.maxHealth))) + if (bossId === 'yian-kut-ku' && Number.isFinite(Number(boss.birdHealth))) { + nextBoss.birdHealth = Math.max(1, Math.round(Number(boss.birdHealth))) + } + if (Object.keys(nextBoss).length > 0) overrides.bosses[bossId] = nextBoss + } + return overrides +} + +function writeIwt2BalanceOverrides(overrides) { + const source = [ + "import type { Iwt2BalanceOverrides } from './bosses'", + '', + '// Generated by local admin panel. Commit this file with intended IWT2 balance changes.', + `export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = ${JSON.stringify(sanitizeIwt2BalanceOverrides(overrides), null, 2)}`, + '', + ].join('\n') + writeFileSync(iwt2BalanceOverridesPath, source, { mode: 0o644 }) +} + +function updateIwt2EntityHp(entityId, payload) { + const maxHealth = Number(payload.maxHealth) + if (!Number.isFinite(maxHealth) || maxHealth <= 0) throw new Error('HP must be greater than zero.') + const roundedHealth = Math.round(maxHealth) + const overrides = readIwt2BalanceOverrides() + const bosses = { ...(overrides.bosses ?? {}) } + if (entityId === 'bulldrome' || entityId === 'yian-kut-ku') { + bosses[entityId] = { + ...(bosses[entityId] ?? {}), + maxHealth: roundedHealth, + } + } else if (entityId === 'yian-kut-ku:yian-bird' || entityId === 'yian-bird') { + bosses['yian-kut-ku'] = { + ...(bosses['yian-kut-ku'] ?? {}), + birdHealth: roundedHealth, + } + } else { + throw new Error('IWT2 entity not found.') + } + const nextOverrides = sanitizeIwt2BalanceOverrides({ bosses }) + writeIwt2BalanceOverrides(nextOverrides) + return nextOverrides +} + +function listIwt2BossAssets() { + if (!existsSync(bossImageDirectory)) return [] + return readdirSync(bossImageDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && bossImageContentTypes[extname(entry.name).toLowerCase()]) + .map((entry) => { + const imagePath = resolve(bossImageDirectory, entry.name) + return { + filename: entry.name, + size: statSync(imagePath).size, + url: `/api/boss-images/${entry.name}`, + } + }) + .sort((a, b) => a.filename.localeCompare(b.filename)) +} + function sendFile(response, filePath) { const contentTypes = { '.css': 'text/css; charset=utf-8', @@ -308,6 +392,22 @@ const server = createServer(async (request, response) => { return } + if (request.url === '/api/admin/iwt2/data' && request.method === 'GET') { + sendJson(response, 200, { + balanceOverrides: readIwt2BalanceOverrides(), + bossAssets: listIwt2BossAssets(), + }) + return + } + + const iwt2HpMatch = request.url.match(/^\/api\/admin\/iwt2\/entities\/([^/]+)\/hp$/) + if (iwt2HpMatch && request.method === 'PUT') { + const payload = await readJson(request) + const balanceOverrides = updateIwt2EntityHp(decodeURIComponent(iwt2HpMatch[1]), payload) + sendJson(response, 200, { ok: true, balanceOverrides }) + return + } + if (!existsSync(databasePath)) { sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' }) return diff --git a/src/App.css b/src/App.css index 646e789..779a71d 100644 --- a/src/App.css +++ b/src/App.css @@ -14,6 +14,779 @@ --pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace; } +.game-version-shell, +.iwt2-shell, +.iwt2-arena-shell { + min-height: 100vh; +} + +.game-shell.iwt2-arena-shell { + height: 100vh; + left: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100vw; +} + +.game-version-screen, +.iwt2-menu-screen { + display: grid; + gap: 18px; + margin: 0 auto; + max-width: 1040px; + padding: 28px 22px; + width: 100%; +} + +.game-version-heading, +.iwt2-menu-heading { + border-bottom: 2px solid #34343d; + padding-bottom: 18px; +} + +.game-version-heading h1, +.iwt2-menu-heading h1 { + font-size: clamp(2rem, 5vw, 4rem); + line-height: 1; + margin: 0; +} + +.game-version-grid, +.iwt2-menu-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.iwt2-menu-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.game-version-card, +.iwt2-menu-card { + align-items: center; + background: #151921; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 12px; + grid-template-columns: 54px minmax(0, 1fr); + min-height: 118px; + outline: 2px solid #3a4250; + padding: 12px; + text-align: left; +} + +.game-version-card > span, +.iwt2-menu-card > span { + align-items: center; + background: #202938; + border: 2px solid #090a0d; + color: #f1c663; + display: inline-flex; + font-family: var(--pixel-font); + font-size: 1.15rem; + height: 54px; + justify-content: center; + width: 54px; +} + +.game-version-card strong, +.iwt2-menu-card strong { + display: block; + font-size: 1rem; + margin-bottom: 6px; +} + +.game-version-card small, +.iwt2-menu-card small, +.game-version-card p { + color: var(--muted); + display: block; + font-size: 0.78rem; + line-height: 1.4; + margin: 0; +} + +.iwt2-arena-layout { + height: 100vh; + overflow: hidden; + padding: 0; + width: 100vw; +} + +.iwt2-arena-stage { + background: #090c10; + border: 0; + height: 100%; + min-height: 0; + overflow: hidden; + position: relative; + width: 100%; +} + +.iwt2-phaser-host { + height: 100%; + width: 100%; +} + +.iwt2-phaser-host canvas { + display: block; +} + +.iwt2-arena-rail { + display: grid; + gap: 10px; + grid-template-rows: auto minmax(0, 1fr) auto auto; + min-height: 0; +} + +.iwt2-boss-hud { + left: 50%; + min-width: 310px; + padding: 8px 12px 10px; + pointer-events: none; + position: absolute; + top: 10px; + transform: translateX(-50%); + z-index: 4; +} + +.iwt2-boss-hud > div:first-child { + align-items: baseline; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.iwt2-boss-hud strong { + font-size: 0.95rem; + text-shadow: 0 2px 0 #050608; +} + +.iwt2-boss-phase { + color: var(--muted); + display: block; + font-size: 0.58rem; + height: 12px; + line-height: 12px; + margin-top: 2px; + overflow: hidden; + text-transform: uppercase; +} + +.iwt2-boss-panel, +.iwt2-party-list, +.iwt2-status-panel, +.iwt2-event-log, +.iwt2-list-panel { + background: #141922; + border: 2px solid #090a0d; + outline: 2px solid #34343d; + padding: 12px; +} + +.iwt2-boss-panel h2 { + margin: 0 0 10px; +} + +.iwt2-bar { + background: #050608; + border: 1px solid #0a0c10; + height: 14px; + overflow: hidden; + position: relative; +} + +.iwt2-bar span { + display: block; + height: 100%; +} + +.iwt2-bar i { + background: #58a8ff; + display: block; + height: 100%; + position: absolute; + right: 0; + top: 0; +} + +.iwt2-bar.boss span { + background: #dc5162; +} + +.iwt2-bar.hp span { + background: #52c47b; +} + +.iwt2-bar.mana span { + background: #4f91e8; +} + +.iwt2-party-row .iwt2-bar { + height: 10px; + margin-top: 4px; +} + +.iwt2-party-list { + background: rgba(15, 19, 26, 0.88); + border: 0; + left: 28px; + max-height: calc(100% - 112px); + outline: 0; + padding: 6px; + position: absolute; + top: 28px; + width: 172px; + z-index: 4; + display: grid; + gap: 8px; + align-content: start; + overflow: auto; +} + +.iwt2-party-row { + align-items: center; + background: #10141b; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 8px; + grid-template-columns: 24px minmax(0, 1fr); + height: 86px; + min-height: 86px; + outline: 2px solid transparent; + padding: 5px; + text-align: left; + width: 100%; +} + +.iwt2-party-row.has-target-binding { + grid-template-columns: 28px 24px minmax(0, 1fr); +} + +.iwt2-party-row > span { + align-items: center; + border: 2px solid #050608; + color: #fff7df; + display: inline-flex; + font-weight: 900; + height: 24px; + justify-content: center; + width: 24px; +} + +.iwt2-party-row .iwt2-party-target-key { + background: #050608; + border-color: #0b0d12; + color: var(--muted); + font-family: var(--pixel-font); + font-size: 0.52rem; + height: 24px; + min-width: 24px; + padding: 0 2px; + width: auto; +} + +.iwt2-party-row .iwt2-party-target-key .controller-face-icon { + height: 20px; + min-width: 20px; +} + +.iwt2-party-row-title { + align-items: baseline; + display: flex; + gap: 6px; + justify-content: space-between; + min-width: 0; +} + +.iwt2-party-row-title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iwt2-party-row-title small { + flex: 0 0 auto; +} + +.iwt2-party-row strong, +.iwt2-status-panel strong { + display: block; + font-size: 0.72rem; +} + +.iwt2-party-row small, +.iwt2-status-panel small, +.iwt2-event-log small, +.iwt2-boss-panel small { + color: var(--muted); + display: block; + font-size: 0.62rem; + line-height: 1.35; +} + +.iwt2-party-effects { + align-items: center; + display: flex; + gap: 3px; + height: 16px; + margin-top: 4px; + overflow: hidden; +} + +.iwt2-effect-badge { + border: 1px solid #050608; + color: #fff7df; + display: inline-block; + flex: 0 1 auto; + font-size: 0.52rem; + line-height: 12px; + max-width: 74px; + overflow: hidden; + padding: 0 3px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iwt2-effect-badge.hot { + background: #256f4c; +} + +.iwt2-effect-badge.shield { + background: #245c9f; +} + +.iwt2-party-damage { + height: 12px; + overflow: hidden; + white-space: nowrap; +} + +.iwt2-party-row.selected { + background: #18202b; +} + +.iwt2-event-log { + bottom: 10px; + display: none; +} + +.iwt2-ability-bar { + bottom: 12px; + display: grid; + gap: 8px; + grid-template-columns: repeat(6, 42px); + justify-content: center; + left: 50%; + max-width: 308px; + position: absolute; + transform: translateX(-50%); + width: 308px; + z-index: 4; +} + +.iwt2-ability-button { + align-items: center; + background: rgba(16, 20, 27, 0.9); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + justify-content: center; + min-height: 50px; + outline: 2px solid #34343d; + padding: 4px; + position: relative; + text-align: center; +} + +.iwt2-ability-button:disabled { + cursor: not-allowed; + filter: grayscale(0.85); + opacity: 0.55; +} + +.iwt2-ability-button > span { + align-items: center; + background: #253244; + border: 2px solid #050608; + color: #fff4a8; + display: inline-flex; + font-size: 1rem; + height: 24px; + justify-content: center; + width: 24px; +} + +.iwt2-ability-button strong { + color: #fff4a8; + font-size: 0.56rem; + position: absolute; + right: 3px; + top: 1px; +} + +.iwt2-ability-button i { + color: var(--muted); + display: block; + font-size: 0.5rem; + font-style: normal; + line-height: 1.2; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +.iwt2-arena-overlay { + align-content: center; + background: + radial-gradient(circle at 50% 42%, rgba(229, 185, 95, 0.16), transparent 32%), + rgba(8, 10, 13, 0.84); + display: grid; + inset: 0; + justify-items: center; + padding: 18px; + position: absolute; + text-align: center; + z-index: 5; +} + +.iwt2-result-panel { + background: linear-gradient(180deg, rgba(28, 33, 43, 0.98), rgba(12, 15, 20, 0.98)); + border: 2px solid #0a0c10; + box-shadow: 0 0 0 2px #45414a, 0 18px 42px rgba(0, 0, 0, 0.5); + display: grid; + gap: 12px; + justify-items: center; + max-width: 520px; + min-width: min(520px, calc(100vw - 36px)); + padding: 20px 22px 18px; +} + +.iwt2-result-panel .eyebrow { + color: #f1c663; + font-size: 0.72rem; + letter-spacing: 0; + line-height: 1; + margin: 0; + text-transform: uppercase; +} + +.iwt2-result-panel h1 { + color: #fff7df; + font-size: 1.8rem; + line-height: 1; + margin: 0; + text-shadow: 0 3px 0 #050608; +} + +.iwt2-result-crest { + align-items: center; + background: + linear-gradient(135deg, color-mix(in srgb, var(--boss-color) 72%, #050608), #10141b); + border: 2px solid #050608; + box-shadow: 0 0 0 2px var(--boss-accent), 0 10px 24px rgba(0, 0, 0, 0.35); + color: #fff7df; + display: inline-flex; + font-size: 1.7rem; + font-weight: 900; + height: 58px; + justify-content: center; + line-height: 1; + width: 58px; +} + +.iwt2-result-crest span { + transform: translateY(-1px); +} + +.iwt2-result-summary { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; +} + +.iwt2-result-summary span, +.iwt2-result-reward span { + background: rgba(8, 10, 13, 0.72); + border: 1px solid #2f3440; + color: var(--muted); + display: grid; + font-size: 0.62rem; + gap: 3px; + min-height: 48px; + padding: 7px 8px; + place-items: center; + text-transform: uppercase; +} + +.iwt2-result-summary strong { + color: #fff7df; + display: block; + font-size: 1rem; + line-height: 1; +} + +.iwt2-result-reward { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.iwt2-result-reward span { + color: #fff4a8; + display: inline-flex; + min-height: 30px; + padding: 7px 10px; +} + +.iwt2-arena-overlay.is-defeat { + background: + radial-gradient(circle at 50% 42%, rgba(167, 53, 67, 0.18), transparent 34%), + rgba(8, 10, 13, 0.88); +} + +.iwt2-arena-overlay.is-paused { + background: rgba(8, 10, 13, 0.72); +} + +.iwt2-arena-overlay.is-defeat .iwt2-result-panel .eyebrow { + color: #ff8a96; +} + +.iwt2-result-hint { + color: var(--muted); + font-size: 0.58rem; +} + +.iwt2-overlay-actions { + display: flex; + gap: 12px; + justify-content: center; + width: 100%; +} + +.iwt2-overlay-actions button { + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + min-height: 44px; + min-width: 120px; + outline: 2px solid #34343d; + padding: 0 14px; +} + +.iwt2-result-button.is-primary { + background: #263448; + color: #fff4a8; +} + +.iwt2-result-button.is-secondary { + background: #141922; + color: var(--ink); +} + +.iwt2-result-button.game-selected { + background: #2b3b52; + color: #fff7df; +} + +.iwt2-list-screen, +.iwt2-screen-shell { + display: grid; + gap: 18px; + margin: 0 auto; + max-width: 1040px; + padding: 24px 22px 36px; + width: 100%; +} + +.iwt2-list-panel { + display: grid; + gap: 10px; +} + +.iwt2-list-row { + align-items: center; + background: #10141b; + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) auto auto; + min-height: 44px; + padding: 10px; +} + +.iwt2-profile-grid, +.iwt2-settings-panel { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.iwt2-action-list { + display: grid; + gap: 10px; +} + +.iwt2-action-row { + align-items: center; + background: #10141b; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) auto; + min-height: 64px; + outline: 2px solid #34343d; + padding: 12px 14px; + text-align: left; +} + +.iwt2-action-row strong { + display: block; + font-size: 0.95rem; +} + +.iwt2-action-row small { + color: var(--muted); + display: block; + font-size: 0.72rem; + line-height: 1.35; + margin-top: 4px; +} + +.iwt2-action-row em { + color: #fff4a8; + font-style: normal; + white-space: nowrap; +} + +.iwt2-action-row.selected, +.iwt2-menu-card.game-selected { + background: #1c2633; + outline-color: #e5b95f; +} + +.iwt2-controller-preview { + align-content: center; + background: #141922; + border: 2px solid #090a0d; + display: grid; + gap: 14px; + justify-items: center; + min-height: 160px; + outline: 2px solid #34343d; + padding: 18px; +} + +.iwt2-controller-preview > span { + color: var(--ink); + font-weight: 800; +} + +.iwt2-bottom-display { + height: 100vh; + overflow: hidden; + width: 100vw; +} + +.iwt2-bottom-display.dual-bottom-display { + grid-template-rows: auto auto minmax(0, 1fr); +} + +@media (max-width: 760px) { + .game-version-grid, + .iwt2-menu-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .game-version-screen, + .iwt2-menu-screen { + padding: 22px 14px; + } + + .game-version-card, + .iwt2-menu-card { + min-height: 130px; + } + + .iwt2-profile-grid, + .iwt2-settings-panel { + grid-template-columns: 1fr; + } + + .iwt2-arena-layout { + max-height: none; + } + + .iwt2-arena-rail { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + } + + .iwt2-party-list { + max-height: 148px; + width: 172px; + } + + .iwt2-ability-bar { + grid-template-columns: repeat(6, 36px); + width: 256px; + } + + .iwt2-ability-button { + min-height: 46px; + } + + .iwt2-ability-button > span { + height: 28px; + width: 28px; + } + + .iwt2-arena-overlay { + padding: 12px; + } + + .iwt2-result-panel { + gap: 9px; + max-width: 420px; + min-width: min(420px, calc(100vw - 24px)); + padding: 14px; + } + + .iwt2-result-panel h1 { + font-size: 1.35rem; + } + + .iwt2-result-crest { + font-size: 1.35rem; + height: 46px; + width: 46px; + } + + .iwt2-result-summary span { + min-height: 42px; + } + + .iwt2-result-reward span { + min-height: 28px; + } +} + .combat-touch-lock-status { background: #101216; border: 2px solid #d9b55a; @@ -8897,16 +9670,292 @@ h2 { font-style: normal; } +.iwt2-admin-panel { + gap: 14px; +} + +.iwt2-admin-summary { + background: #1c1e25; + border: 2px solid #090a0d; + color: var(--muted); + display: flex; + flex-wrap: wrap; + font-family: var(--pixel-font); + font-size: 7px; + gap: 14px; + outline: 2px solid #494754; + padding: 10px 12px; + text-transform: uppercase; +} + +.iwt2-admin-summary span { + color: var(--gold); +} + +.iwt2-admin-layout { + align-items: start; + display: grid; + gap: 14px; + grid-template-columns: minmax(250px, 0.34fr) minmax(0, 1fr); +} + +.iwt2-admin-entity-list, +.iwt2-admin-detail { + background: var(--panel-light); + border: 2px solid #090a0d; + outline: 2px solid #494754; + padding: 12px; +} + +.iwt2-admin-entity-list { + display: flex; + flex-direction: column; + gap: 8px; + position: sticky; + top: 12px; +} + +.iwt2-admin-entity-list button { + align-items: center; + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: 42px minmax(0, 1fr); + min-height: 64px; + outline: 2px solid #34343d; + padding: 9px; + text-align: left; +} + +.iwt2-admin-entity-list button.active, +.iwt2-admin-entity-list button:hover { + background: #1f2633; + outline-color: var(--gold); +} + +.iwt2-admin-entity-list strong, +.iwt2-admin-entity-list small, +.iwt2-admin-detail-hero small { + display: block; +} + +.iwt2-admin-entity-list small, +.iwt2-admin-detail-hero small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + margin-top: 4px; +} + +.iwt2-admin-detail { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.iwt2-admin-detail-hero { + align-items: center; + background: #15161c; + border: 2px solid #090a0d; + display: grid; + gap: 12px; + grid-template-columns: 54px minmax(0, 1fr); + outline: 2px solid #41404a; + padding: 12px; +} + +.iwt2-admin-detail-hero .iwt2-admin-token { + height: 54px; + width: 54px; +} + +.iwt2-admin-detail-hero h2 { + font-family: var(--pixel-font); + font-size: 16px; + line-height: 1.1; + margin: 0; +} + +.iwt2-admin-section { + display: grid; + gap: 10px; +} + +.iwt2-admin-section-heading { + align-items: baseline; + display: flex; + justify-content: space-between; +} + +.iwt2-admin-section-heading h3 { + color: var(--ink); + font-family: var(--pixel-font); + font-size: 10px; + margin: 0; + text-transform: uppercase; +} + +.iwt2-admin-section-heading span { + color: var(--gold); + font-family: var(--pixel-font); + font-size: 7px; + text-transform: uppercase; +} + +.iwt2-admin-token, +.iwt2-admin-swatch > span { + align-items: center; + background: var(--iwt2-entity-color, var(--gold)); + border: 2px solid #090a0d; + color: #101115; + display: flex; + font-family: var(--pixel-font); + font-size: 14px; + height: 42px; + justify-content: center; + outline: 2px solid #5a5964; + width: 42px; +} + +.iwt2-admin-assets { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); +} + +.iwt2-admin-assets a, +.iwt2-admin-swatch { + background: #15161c; + border: 2px solid #090a0d; + color: var(--muted); + display: flex; + flex-direction: column; + gap: 8px; + min-height: 178px; + outline: 2px solid #41404a; + padding: 10px; + text-decoration: none; +} + +.iwt2-admin-assets a:hover { + color: var(--ink); + outline-color: var(--gold); +} + +.iwt2-admin-assets img { + aspect-ratio: 16 / 10; + background: #1c1e25; + border: 2px solid #090a0d; + object-fit: cover; + width: 100%; +} + +.iwt2-admin-assets strong, +.iwt2-admin-swatch strong { + color: var(--ink); + display: block; + font-size: 13px; + line-height: 1.2; +} + +.iwt2-admin-assets small, +.iwt2-admin-swatch small { + font-size: 10px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.iwt2-admin-hp { + align-items: center; + background: #15161c; + border: 2px solid #090a0d; + color: var(--gold); + display: grid; + font-family: var(--pixel-font); + font-size: 8px; + gap: 10px; + grid-template-columns: 34px minmax(90px, 1fr) auto; + outline: 2px solid #41404a; + padding: 10px; + text-transform: uppercase; +} + +.iwt2-admin-hp input { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + font-family: var(--pixel-font); + font-size: 8px; + outline: 2px solid #3e3d47; + padding: 8px 10px; + width: 100%; +} + +.iwt2-admin-hp input:focus { + outline-color: var(--gold); +} + +.iwt2-admin-attacks { + display: grid; + gap: 6px; +} + +.iwt2-admin-attack-head, +.iwt2-admin-attack-row { + display: grid; + gap: 8px; + grid-template-columns: minmax(100px, 0.9fr) minmax(100px, 0.9fr) minmax(90px, 0.7fr); +} + +.iwt2-admin-attack-head { + color: var(--gold); + font-family: var(--pixel-font); + font-size: 7px; + padding: 0 8px; + text-transform: uppercase; +} + +.iwt2-admin-attack-row { + background: #15161c; + border: 2px solid #090a0d; + color: var(--muted); + font-size: 12px; + outline: 2px solid #41404a; + padding: 8px; +} + +.iwt2-admin-attack-row strong { + color: var(--ink); +} + +.iwt2-admin-attack-row small { + color: var(--muted); + grid-column: 1 / -1; +} + @media (max-width: 800px) { .admin-upgrade-toolbar, .admin-upgrade-step, .admin-class-layout, .admin-class-hero, .admin-class-table-head, - .admin-class-row { + .admin-class-row, + .iwt2-admin-layout, + .iwt2-admin-detail-hero, + .iwt2-admin-hp, + .iwt2-admin-attack-head, + .iwt2-admin-attack-row { grid-template-columns: 1fr; } + .iwt2-admin-entity-list { + position: static; + } + .admin-crafting-filters { grid-template-columns: 1fr; } diff --git a/src/App.tsx b/src/App.tsx index cff4def..c7a509f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,1618 +1,86 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from 'react' -import './App.css' -import { AuthScreen } from './components/AuthScreen' -import { - loadCpuPvpLeaderboard, - type CpuPvpLeaderboardEntry, - type PvpContentType, -} from './pvpRoguelike' -import { - loadAuthSession, - logoutAccount, - type Account, - type AuthSession, - type CharacterProfile, -} from './profile' -import { - applyCloudSaveSync, - getCloudSyncStatus, - getGameMode, - previewCloudSaveSync, - type CloudSyncChoice, - type CloudSyncComparison, - type GameMode, -} from './gameRepository' -import { focusFirstControl, useGameAction } from './input.tsx' -import { - useDualScreen, - useDualScreenSetupPublisher, - type DualScreenSetupState, -} from './dualScreen' -import { barFillStyle } from './components/barStyles' +import { useState } from 'react' +import IWantToHeal1App from './modes/iwt1/IWantToHeal1App' +import { IWantToHeal2App } from './modes/iwt2/IWantToHeal2App' +import { useGameAction } from './input' -const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) -const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen }))) -const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen }))) -const HunterProfileScreen = lazy(() => import('./components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen }))) -const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen }))) -const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen }))) -const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen }))) -const SettingsScreen = lazy(() => import('./components/SettingsScreen').then((module) => ({ default: module.SettingsScreen }))) +type GameVersion = 'iwt1' | 'iwt2' -type Screen = - | 'menu' - | 'dungeons' - | 'combat' - | 'raids' - | 'roguelike' - | 'pvp' - | 'customize' - | 'equipment' - | 'hunter-profile' - | 'talents' - | 'settings' - -const MENU_ITEMS: Array<{ - screen: Screen +const GAME_OPTIONS: Array<{ + version: GameVersion + title: string label: string - glyph: string description: string + glyph: string }> = [ - { screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' }, - { screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' }, - { screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' }, - { screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' }, - { screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' }, - { screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' }, - { screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' }, + { + version: 'iwt1', + title: 'I Want To Heal 1', + label: 'Classic Healer Runs', + description: 'Original dungeon, raid, roguelike, PvP, gear, talents, and collection progression.', + glyph: 'I', + }, + { + version: 'iwt2', + title: 'I Want To Heal 2', + label: '2D Boss Arena', + description: 'Move through boss arenas with a visible party, analog movement, projectiles, and separate progression.', + glyph: 'II', + }, ] -const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty' -const SHOW_LEADERBOARDS = false -const ACTIVITY_PAGE_SIZE = 4 -const HOME_MENU_COLUMNS = 2 -const DUNGEON_NAV_COLUMNS = 2 +function App() { + const [selectedVersion, setSelectedVersion] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(0) -type DungeonNavEntry = - | { kind: 'spacer'; disabled: true } - | { kind: 'back'; disabled?: boolean } - | { kind: 'pagePrev'; disabled?: boolean } - | { kind: 'pageNext'; disabled?: boolean } - | { kind: 'activity'; index: number; disabled?: boolean } - | { kind: 'tier'; index: number; disabled?: boolean } - | { kind: 'start'; disabled?: boolean } - | { kind: 'marathon'; disabled?: boolean } - | { kind: 'loot'; disabled?: boolean } - | { kind: 'lootSort'; disabled?: boolean } + useGameAction((action, device) => { + if (selectedVersion || device !== 'controller') return + if (action === 'navigateLeft' || action === 'navigateUp') { + setSelectedIndex((current) => Math.max(0, current - 1)) + } else if (action === 'navigateRight' || action === 'navigateDown') { + setSelectedIndex((current) => Math.min(GAME_OPTIONS.length - 1, current + 1)) + } else if (action === 'confirm') { + setSelectedVersion(GAME_OPTIONS[selectedIndex].version) + } + }) -type RoguelikeNavEntry = - | { kind: 'back' } - | { kind: 'variantPve' } - | { kind: 'variantPvp' } - | { kind: 'pveDungeon' } - | { kind: 'pveRaid' } - | { kind: 'pveStart' } - | { kind: 'pvpDungeon' } - | { kind: 'pvpRaid' } - | { kind: 'pvpStadium' } - | { kind: 'pvpStart' } + if (selectedVersion === 'iwt1') { + return setSelectedVersion(null)} /> + } -type RoguelikeNavPosition = { - row: number - column: number -} + if (selectedVersion === 'iwt2') { + return setSelectedVersion(null)} /> + } -function formatSaveTimestamp(updatedAt: number | null) { - if (!updatedAt) return 'Unknown legacy timestamp' - return new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(updatedAt)) -} - -function cloudSyncRelationText(comparison: CloudSyncComparison) { - if (comparison.relation === 'server-newer') return 'Server save is newer than this device.' - if (comparison.relation === 'local-newer') return 'This device save is newer than the server.' - if (comparison.relation === 'same') return 'Server and this device have the same save time.' - return 'One save has no timestamp yet. Choose the copy you want to keep.' -} - -function activityInitials(name: string) { - return name - .split(/\s+/) - .filter((word) => /^[A-Za-z0-9]/.test(word)) - .slice(0, 2) - .map((word) => word[0].toUpperCase()) - .join('') -} - -function ScreenLoading() { return ( -
-
-

Opening Chronicle

-

Loading...

+
+
+
+

Select Game

+

I Want To Heal

+
+
+ {GAME_OPTIONS.map((option, index) => ( + + ))} +
) } -type RoguelikeVariant = 'pve' | 'pvp' - -function App() { - const { enabled: dualScreenEnabled } = useDualScreen() - const [screen, setScreen] = useState('menu') - const [account, setAccount] = useState(null) - const [profile, setProfile] = useState(null) - const [authChecked, setAuthChecked] = useState(false) - const [gameMode, setGameMode] = useState(getGameMode()) - const [serverMessage, setServerMessage] = useState('') - const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => { - const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY)) - return Number.isFinite(saved) && saved > 0 ? saved : 1 - }) - const [selectedDungeonId, setSelectedDungeonId] = useState(1) - const [selectedRaidId, setSelectedRaidId] = useState(20) - const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon') - const [roguelikeVariant, setRoguelikeVariant] = useState('pve') - const [pvpContentType, setPvpContentType] = useState('dungeon') - const [selectedMarathonMode, setSelectedMarathonMode] = useState(false) - const [activityPage, setActivityPage] = useState(0) - const [combatContentId, setCombatContentId] = useState(1) - const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') - const [showLoot, setShowLoot] = useState(false) - const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence') - const [showLeaderboard, setShowLeaderboard] = useState(false) - const [error, setError] = useState('') - const [syncingCloud, setSyncingCloud] = useState(false) - const [syncMessage, setSyncMessage] = useState('') - const [syncComparison, setSyncComparison] = useState(null) - const [homeSelectedIndex, setHomeSelectedIndex] = useState(0) - const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0) - const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1) - - useEffect(() => { - loadAuthSession() - .then((session) => { - setAccount(session.account) - setProfile(session.profile) - }) - .catch((reason: unknown) => { - setServerMessage( - reason instanceof Error - ? `${reason.message} Offline play is still available.` - : 'Unable to reach the server. Offline play is still available.', - ) - }) - .finally(() => setAuthChecked(true)) - }, []) - - useEffect(() => { - const handleModeChange = (event: Event) => { - const nextMode = (event as CustomEvent).detail - setGameMode(nextMode) - } - window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener) - return () => { - window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener) - } - }, []) - - useEffect(() => { - if (screen === 'combat') return - window.requestAnimationFrame(() => { - focusFirstControl() - }) - }, [screen]) - - useEffect(() => { - if (!authChecked || !account || !profile || screen === 'combat') return - window.requestAnimationFrame(() => { - focusFirstControl() - }) - }, [account, authChecked, profile, screen]) - - useEffect(() => { - window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId)) - }, [selectedDifficultyId]) - - const [cpuLeaderboard, setCpuLeaderboard] = useState([]) - - useEffect(() => { - const frame = window.requestAnimationFrame(() => { - setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) - }) - return () => window.cancelAnimationFrame(frame) - }, [pvpContentType, screen, roguelikeVariant]) - - const profileDungeons = profile?.dungeons - const dungeonOptions = useMemo( - () => profileDungeons?.filter((candidate) => candidate.contentType === 'dungeon') ?? [], - [profileDungeons], - ) - const raidOptions = useMemo( - () => profileDungeons?.filter((candidate) => candidate.contentType === 'raid') ?? [], - [profileDungeons], - ) - const selectedDungeonOption = useMemo( - () => dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0], - [dungeonOptions, selectedDungeonId], - ) - const selectedRaidOption = useMemo( - () => raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0], - [raidOptions, selectedRaidId], - ) - const activityOptions = useMemo( - () => screen === 'raids' ? raidOptions : dungeonOptions, - [dungeonOptions, raidOptions, screen], - ) - const combatDungeonOption = useMemo( - () => { - if (!profileDungeons) return undefined - return combatContentId < 0 - ? profileDungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profileDungeons[0] - : profileDungeons.find((candidate) => candidate.id === combatContentId) ?? profileDungeons[0] - }, - [combatContentId, profileDungeons, roguelikeKind], - ) - const combatDifficultyOption = useMemo( - () => combatDungeonOption?.difficulties.find( - (candidate) => candidate.id === selectedDifficultyId, - ) ?? combatDungeonOption?.difficulties[0], - [combatDungeonOption, selectedDifficultyId], - ) - const roguelikePool = useMemo( - () => profileDungeons - ?.filter((candidate) => candidate.contentType === roguelikeKind) - .flatMap((candidate) => candidate.encounters) ?? [], - [profileDungeons, roguelikeKind], - ) - const pvpPool = useMemo( - () => profileDungeons - ?.filter((candidate) => candidate.contentType === pvpContentType) - .flatMap((candidate) => candidate.encounters) ?? [], - [profileDungeons, pvpContentType], - ) - const tierOptions = useMemo( - () => activityOptions - .flatMap((option) => option.difficulties) - .filter((difficulty, index, all) => ( - all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index - )) - .sort((a, b) => a.droppedItemLevel - b.droppedItemLevel), - [activityOptions], - ) - const savedDifficulty = useMemo( - () => profileDungeons - ?.flatMap((option) => option.difficulties) - .find((candidate) => candidate.id === selectedDifficultyId), - [profileDungeons, selectedDifficultyId], - ) - const selectedTier = useMemo( - () => { - const characterLevel = profile?.character.level ?? 0 - const savedTier = tierOptions.find((candidate) => ( - candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel - && characterLevel >= candidate.unlockLevel - )) - if (savedTier) return savedTier - for (let index = tierOptions.length - 1; index >= 0; index -= 1) { - if (characterLevel >= tierOptions[index].unlockLevel) return tierOptions[index] - } - return tierOptions[0] - }, - [profile?.character.level, savedDifficulty?.droppedItemLevel, tierOptions], - ) - const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0 - const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE)) - const currentActivityPage = Math.min(activityPage, activityPageCount - 1) - const pagedActivityOptions = useMemo( - () => activityOptions.slice( - currentActivityPage * ACTIVITY_PAGE_SIZE, - currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE, - ), - [activityOptions, currentActivityPage], - ) - const selectedActivityOption = useMemo( - () => { - const fallback = screen === 'raids' && selectedRaidOption ? selectedRaidOption : selectedDungeonOption - const selectedActivityId = screen === 'raids' && selectedRaidOption ? selectedRaidOption.id : selectedDungeonOption?.id - return activityOptions.find((candidate) => candidate.id === selectedActivityId) - ?? activityOptions[0] - ?? fallback - }, - [activityOptions, screen, selectedDungeonOption, selectedRaidOption], - ) - const selectedDifficultyOption = useMemo( - () => selectedActivityOption?.difficulties.find( - (candidate) => candidate.droppedItemLevel === selectedTierItemLevel, - ) ?? selectedActivityOption?.difficulties[0], - [selectedActivityOption, selectedTierItemLevel], - ) - const lootPreviewEncounters = useMemo( - () => [...(selectedActivityOption?.encounters ?? [])] - .filter((encounter) => encounter.isBoss) - .sort((a, b) => lootSort === 'boss' - ? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence - : a.sequence - b.sequence), - [lootSort, selectedActivityOption?.encounters], - ) - const lootPreviewByEncounterId = useMemo( - () => new Map(lootPreviewEncounters.map((encounter) => [ - encounter.id, - encounter.lootTables.filter((entry) => entry.difficultyId === selectedDifficultyOption?.id), - ])), - [lootPreviewEncounters, selectedDifficultyOption?.id], - ) - const leaderboardEntries = useMemo( - () => selectedActivityOption?.leaderboards[leaderboardCategory].filter( - (entry) => entry.difficultyId === selectedDifficultyOption?.id, - ) ?? [], - [leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id], - ) - - function acceptSession(session: AuthSession) { - setAccount(session.account) - setProfile(session.profile) - setGameMode(getGameMode()) - setScreen('menu') - setError('') - setServerMessage('') - window.requestAnimationFrame(() => { - focusFirstControl() - }) - } - - async function signOut() { - try { - await logoutAccount() - setAccount(null) - setProfile(null) - setGameMode(getGameMode()) - setScreen('menu') - setSyncMessage('') - setSyncComparison(null) - } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Unable to sign out.') - } - } - - async function syncSaveNow() { - setSyncingCloud(true) - setSyncMessage('') - setSyncComparison(null) - try { - const comparison = await previewCloudSaveSync() - setSyncComparison(comparison) - setSyncMessage('') - } catch (reason) { - setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') - } finally { - setSyncingCloud(false) - } - } - - async function keepCloudSave(choice: CloudSyncChoice) { - setSyncingCloud(true) - setSyncMessage(choice === 'local' ? 'Uploading this device save...' : 'Downloading server save...') - try { - const updated = await applyCloudSaveSync(choice) - setProfile(updated) - setGameMode(getGameMode()) - setSyncComparison(null) - setSyncMessage(choice === 'local' - ? 'Server now uses this device save.' - : 'This device now uses the server save.') - } catch (reason) { - setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') - } finally { - setSyncingCloud(false) - } - } - - const cloudSync = getCloudSyncStatus() - const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available) - const cloudSyncEntryCount = canShowCloudSync ? (syncComparison ? 3 : 1) : 0 - const homeMenuOffset = cloudSyncEntryCount - const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset - const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1) - - function openHomeMenuIndex(index: number) { - if (canShowCloudSync && index === 0) { - if (!syncingCloud) void syncSaveNow() - return - } - if (canShowCloudSync && syncComparison && index === 1) { - if (!syncingCloud) void keepCloudSave('local') - return - } - if (canShowCloudSync && syncComparison && index === 2) { - if (!syncingCloud) void keepCloudSave('server') - return - } - const item = MENU_ITEMS[index - homeMenuOffset] - if (!item) return - if (item.screen === 'pvp') { - setRoguelikeVariant('pvp') - setRoguelikeSelectedIndex(2) - setScreen('roguelike') - return - } - if (item.screen === 'roguelike') { - setRoguelikeSelectedIndex(1) - } - if (item.screen === 'dungeons' || item.screen === 'raids') { - const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions - setActivityPage(0) - setDungeonSelectedIndex(firstActivityDungeonEntryIndexForPageCount( - Math.max(1, Math.ceil(nextOptions.length / ACTIVITY_PAGE_SIZE)), - )) - } - setScreen(item.screen) - } - - function moveHomeSelection(action: string) { - setHomeSelectedIndex((current) => { - const bounded = Math.min(current, homeMenuEntryCount - 1) - if (canShowCloudSync && syncComparison && bounded <= 2) { - if (bounded === 0) { - return action === 'navigateDown' || action === 'navigateRight' ? 1 : 0 - } - if (bounded === 1) { - if (action === 'navigateRight') return 2 - if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1) - return 0 - } - if (action === 'navigateLeft') return 1 - if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1) - return 0 - } - if (canShowCloudSync && !syncComparison && bounded === 0) { - return action === 'navigateDown' || action === 'navigateRight' - ? Math.min(homeMenuOffset, homeMenuEntryCount - 1) - : 0 - } - - const menuIndex = bounded - homeMenuOffset - if (menuIndex < 0) return bounded - const column = menuIndex % HOME_MENU_COLUMNS - if (action === 'navigateLeft') { - if (column > 0) return bounded - 1 - if (canShowCloudSync && syncComparison && menuIndex < HOME_MENU_COLUMNS) return 2 - return bounded - } - if (action === 'navigateRight') { - const nextMenuIndex = menuIndex + 1 - return column < HOME_MENU_COLUMNS - 1 && nextMenuIndex < MENU_ITEMS.length ? bounded + 1 : bounded - } - if (action === 'navigateUp') { - const previousMenuIndex = menuIndex - HOME_MENU_COLUMNS - if (previousMenuIndex >= 0) return homeMenuOffset + previousMenuIndex - if (canShowCloudSync && syncComparison) return column === 0 ? 1 : 2 - if (canShowCloudSync) return 0 - return bounded - } - const nextMenuIndex = menuIndex + HOME_MENU_COLUMNS - return nextMenuIndex < MENU_ITEMS.length ? homeMenuOffset + nextMenuIndex : bounded - }) - } - - function dungeonEntries() { - const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0] - const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true - const entries: DungeonNavEntry[] = [ - { kind: 'back' }, - ] - if (activityPageCount > 1) { - entries.push( - { kind: 'pagePrev', disabled: currentActivityPage === 0 }, - { kind: 'pageNext', disabled: currentActivityPage >= activityPageCount - 1 }, - ) - } - if (entries.length % DUNGEON_NAV_COLUMNS !== 0) { - entries.push({ kind: 'spacer', disabled: true }) - } - pagedActivityOptions.forEach((candidate, index) => { - const candidateDifficulty = difficulty - ? candidate.difficulties.find( - (option) => option.droppedItemLevel === difficulty.droppedItemLevel, - ) ?? candidate.difficulties[0] - : candidate.difficulties[0] - entries.push({ - kind: 'activity', - index, - disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel, - }) - }) - entries.push( - { kind: 'start', disabled: locked }, - { kind: 'marathon', disabled: locked }, - ) - tierOptions.forEach((difficultyOption, index) => { - entries.push({ - kind: 'tier', - index, - disabled: !profile || profile.character.level < difficultyOption.unlockLevel, - }) - }) - entries.push({ kind: 'loot' }) - if (showLoot) entries.push({ kind: 'lootSort' }) - return entries - } - - function firstEnabledDungeonEntry(entries: DungeonNavEntry[]) { - return Math.max(0, entries.findIndex((entry) => !entry.disabled)) - } - - function firstActivityDungeonEntryIndexForPageCount(pageCount: number) { - const entryCountBeforeActivities = pageCount > 1 ? 3 : 1 - return entryCountBeforeActivities % DUNGEON_NAV_COLUMNS === 0 - ? entryCountBeforeActivities - : entryCountBeforeActivities + 1 - } - - function activeDungeonEntry(entries = dungeonEntries()) { - if (entries[dungeonSelectedIndex] && !entries[dungeonSelectedIndex].disabled) { - return entries[dungeonSelectedIndex] - } - const firstEnabled = firstEnabledDungeonEntry(entries) - return entries[firstEnabled] ?? entries[0] - } - - function dungeonEntrySelected(entry: DungeonNavEntry['kind'], index?: number) { - const active = activeDungeonEntry() - return active?.kind === entry && ('index' in active ? active.index === index : index === undefined) - } - - function moveDungeonSelection(action: string) { - const entries = dungeonEntries() - if (entries.length === 0) return - setDungeonSelectedIndex((current) => { - const bounded = entries[current] && !entries[current].disabled - ? current - : firstEnabledDungeonEntry(entries) - const column = bounded % DUNGEON_NAV_COLUMNS - const direction = action === 'navigateLeft' || action === 'navigateUp' ? -1 : 1 - const step = action === 'navigateUp' || action === 'navigateDown' ? DUNGEON_NAV_COLUMNS : 1 - if (action === 'navigateLeft' && column === 0) return bounded - if (action === 'navigateRight' && column === DUNGEON_NAV_COLUMNS - 1) return bounded - for (let next = bounded + direction * step; next >= 0 && next < entries.length; next += direction * step) { - if (!entries[next].disabled) return next - } - return bounded - }) - } - - function selectActivityByPageIndex(index: number) { - const candidate = pagedActivityOptions[index] - if (!candidate) return - const difficulty = selectedDifficultyOption - ? candidate.difficulties.find( - (option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel, - ) ?? candidate.difficulties[0] - : candidate.difficulties[0] - if (profile && profile.character.level < difficulty.unlockLevel) return - if (screen === 'raids') setSelectedRaidId(candidate.id) - else setSelectedDungeonId(candidate.id) - setSelectedDifficultyId(difficulty.id) - } - - function selectTierByIndex(index: number) { - const difficulty = tierOptions[index] - const activity = selectedActivityOption ?? activityOptions[0] - if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return - setActivityPage(0) - const nextActivity = activity.difficulties.some( - (candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel, - ) - ? activity - : activityOptions.find((option) => - option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel), - ) - if (!nextActivity) return - if (screen === 'raids') setSelectedRaidId(nextActivity.id) - else setSelectedDungeonId(nextActivity.id) - const nextDifficulty = nextActivity.difficulties.find( - (candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel, - ) - if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id) - } - - function startSelectedRun(marathon: boolean) { - const activity = selectedActivityOption ?? activityOptions[0] - const difficulty = selectedDifficultyOption ?? activity?.difficulties[0] - if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return - setSelectedMarathonMode(marathon) - setCombatContentId(activity.id) - setSelectedDifficultyId(difficulty.id) - setScreen('combat') - } - - function openDungeonEntry(entry: DungeonNavEntry | undefined) { - if (!entry || entry.disabled) return - if (entry.kind === 'back') setScreen('menu') - else if (entry.kind === 'pagePrev') setActivityPage((page) => Math.max(0, page - 1)) - else if (entry.kind === 'pageNext') setActivityPage((page) => Math.min(activityPageCount - 1, page + 1)) - else if (entry.kind === 'activity') selectActivityByPageIndex(entry.index) - else if (entry.kind === 'tier') selectTierByIndex(entry.index) - else if (entry.kind === 'start') startSelectedRun(false) - else if (entry.kind === 'marathon') startSelectedRun(true) - else if (entry.kind === 'loot') setShowLoot((current) => !current) - else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence') - } - - function startPveRoguelike() { - const baseDungeon = dungeonOptions[0] - const baseRaid = raidOptions[0] - if (roguelikeKind === 'raid') { - setCombatContentId(-2) - setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101) - } else { - setCombatContentId(-1) - setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1) - } - setSelectedMarathonMode(false) - setScreen('combat') - } - - function roguelikeEntries() { - const entries: RoguelikeNavEntry[] = [ - { kind: 'back' }, - { kind: 'variantPve' }, - { kind: 'variantPvp' }, - ] - if (roguelikeVariant === 'pve') { - entries.push( - { kind: 'pveDungeon' }, - { kind: 'pveRaid' }, - { kind: 'pveStart' }, - ) - } else { - entries.push( - { kind: 'pvpDungeon' }, - { kind: 'pvpRaid' }, - { kind: 'pvpStadium' }, - { kind: 'pvpStart' }, - ) - } - return entries - } - - function activeRoguelikeEntry(entries = roguelikeEntries()) { - return entries[Math.min(roguelikeSelectedIndex, entries.length - 1)] ?? entries[0] - } - - function roguelikeEntrySelected(kind: RoguelikeNavEntry['kind']) { - return activeRoguelikeEntry()?.kind === kind - } - - function roguelikeEntryPosition(entry: RoguelikeNavEntry): RoguelikeNavPosition { - if (entry.kind === 'back') return { row: 0, column: 0 } - if (entry.kind === 'variantPve') return { row: 1, column: 0 } - if (entry.kind === 'variantPvp') return { row: 1, column: 1 } - if (entry.kind === 'pveDungeon' || entry.kind === 'pvpDungeon') return { row: 2, column: 0 } - if (entry.kind === 'pveRaid' || entry.kind === 'pvpRaid') return { row: 2, column: 1 } - if (entry.kind === 'pvpStart') return { row: 3, column: 1 } - return { row: 3, column: 0 } - } - - function moveRoguelikeSelection(action: string) { - const entries = roguelikeEntries() - setRoguelikeSelectedIndex((current) => { - const bounded = Math.min(current, entries.length - 1) - const active = entries[bounded] - if (!active) return bounded - const activePosition = roguelikeEntryPosition(active) - const candidates = entries - .map((entry, index) => ({ entry, index, position: roguelikeEntryPosition(entry) })) - .filter(({ index, position }) => { - if (index === bounded) return false - if (action === 'navigateLeft') return position.row === activePosition.row && position.column < activePosition.column - if (action === 'navigateRight') return position.row === activePosition.row && position.column > activePosition.column - if (action === 'navigateUp') return position.row < activePosition.row - return position.row > activePosition.row - }) - if (candidates.length === 0) return bounded - candidates.sort((a, b) => { - const aPrimary = Math.abs(a.position.row - activePosition.row) - + Math.abs(a.position.column - activePosition.column) - const bPrimary = Math.abs(b.position.row - activePosition.row) - + Math.abs(b.position.column - activePosition.column) - const aSecondary = action === 'navigateLeft' || action === 'navigateRight' - ? Math.abs(a.position.column - activePosition.column) - : Math.abs(a.position.row - activePosition.row) - const bSecondary = action === 'navigateLeft' || action === 'navigateRight' - ? Math.abs(b.position.column - activePosition.column) - : Math.abs(b.position.row - activePosition.row) - return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index - }) - return candidates[0]?.index ?? bounded - }) - } - - function openRoguelikeEntry(entry: RoguelikeNavEntry | undefined) { - if (!entry) return - if (entry.kind === 'back') setScreen('menu') - else if (entry.kind === 'variantPve') setRoguelikeVariant('pve') - else if (entry.kind === 'variantPvp') setRoguelikeVariant('pvp') - else if (entry.kind === 'pveDungeon') setRoguelikeKind('dungeon') - else if (entry.kind === 'pveRaid') setRoguelikeKind('raid') - else if (entry.kind === 'pveStart') startPveRoguelike() - else if (entry.kind === 'pvpDungeon') setPvpContentType('dungeon') - else if (entry.kind === 'pvpRaid') setPvpContentType('raid') - else if (entry.kind === 'pvpStadium') setPvpContentType('stadium') - else if (entry.kind === 'pvpStart') setScreen('pvp') - } - - useGameAction((action, device) => { - if (device !== 'controller') return - if (screen === 'menu') { - if (action === 'confirm') { - openHomeMenuIndex(homeActiveIndex) - return - } - if (!action.startsWith('navigate')) return - moveHomeSelection(action) - return - } - if (screen === 'dungeons' || screen === 'raids') { - if (action === 'back') { - setScreen('menu') - return - } - if (action === 'confirm') { - openDungeonEntry(activeDungeonEntry()) - return - } - if (action.startsWith('navigate')) moveDungeonSelection(action) - } - if (screen === 'roguelike') { - if (action === 'back') { - setScreen('menu') - return - } - if (action === 'confirm') { - openRoguelikeEntry(activeRoguelikeEntry()) - return - } - if (action.startsWith('navigate')) moveRoguelikeSelection(action) - } - }) - - const setupDualScreenState = useMemo(() => { - if ( - !(screen === 'dungeons' || screen === 'raids') - || !profile - || !selectedActivityOption - || !selectedDifficultyOption - ) return null - const locked = profile.character.level < selectedDifficultyOption.unlockLevel - return { - contentType: selectedActivityOption.contentType, - title: selectedActivityOption.name, - subtitle: `${selectedActivityOption.locationName} | Level ${selectedActivityOption.recommendedLevel} | ${selectedActivityOption.partySize} Players`, - description: selectedActivityOption.description, - initials: activityInitials(selectedActivityOption.name), - difficultyName: selectedDifficultyOption.name, - itemLevel: selectedDifficultyOption.droppedItemLevel, - experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier), - lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined, - stats: { - health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`, - damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`, - xp: `${selectedDifficultyOption.experienceMultiplier.toFixed(1)}x`, - loot: `iLvl ${selectedDifficultyOption.droppedItemLevel}`, - }, - } - }, [profile, screen, selectedActivityOption, selectedDifficultyOption]) - - useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled) - - if (error) { - return ( -
-
-

Database Error

-

Character Unavailable

-

{error}

-
-
- ) - } - - if (!authChecked) { - return ( -
-
-

Opening Chronicle

-

Loading...

-
-
- ) - } - - if (!account || !profile) { - return ( - - ) - } - - if (screen === 'combat') { - const dungeon = combatDungeonOption ?? profile.dungeons[0] - const difficulty = combatDifficultyOption ?? dungeon.difficulties[0] - return ( - }> - 0} - profile={profile} - roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} - roguelikeUpgradeTiming={combatContentId < 0 ? 'encounter' : undefined} - roguelikeAbilityLabelMode={combatContentId < 0 ? 'ability' : undefined} - roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined} - startPart={1} - onExit={() => { - setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') - }} - onProfileUpdated={setProfile} - /> - - ) - } - - if (screen === 'pvp') { - if (pvpContentType === 'stadium') { - return ( - }> - { - setRoguelikeVariant('pvp') - setScreen('roguelike') - }} - onProfileUpdated={setProfile} - profile={profile} - /> - - ) - } - return ( - }> - { - setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) - setRoguelikeVariant('pvp') - setScreen('roguelike') - }} - onProfileUpdated={setProfile} - profile={profile} - /> - - ) - } - - const levelStart = profile.character.currentLevelExperience - const levelEnd = profile.character.nextLevelExperience - const experienceIntoLevel = profile.character.experience - levelStart - const experienceForLevel = Math.max(1, levelEnd - levelStart) - const experiencePercent = profile.character.level >= profile.maxLevel - ? 100 - : Math.min(100, (experienceIntoLevel / experienceForLevel) * 100) - const dungeon = selectedDungeonOption ?? profile.dungeons[0]! - const activityPageStart = activityOptions.length === 0 - ? 0 - : currentActivityPage * ACTIVITY_PAGE_SIZE + 1 - const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE) - const activity = selectedActivityOption ?? dungeon - const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0] - const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel - return ( -
- {screen !== 'hunter-profile' && ( -
- -
- {profile.character.name} - Level {profile.character.level} - Item Level {profile.gearStats.averageItemLevel.toFixed(1)} -
- -
- -
-
- )} - - {screen === 'menu' && ( -
-
- {canShowCloudSync && ( -
setHomeSelectedIndex(0)} - > - {syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'} -
- Sync With Server - - {syncComparison - ? cloudSyncRelationText(syncComparison) - : cloudSync.dirty - ? 'Local progress waiting. Upload when you want to refresh the server copy.' - : 'Server copy matches this device.'} - - {syncComparison && ( -
- Device: {formatSaveTimestamp(syncComparison.local.updatedAt)} - Server: {formatSaveTimestamp(syncComparison.server.updatedAt)} -
- )} - {syncMessage && {syncMessage}} -
-
- - {syncComparison && ( -
- - -
- )} -
-
- )} - {MENU_ITEMS.map((item, index) => { - const homeIndex = index + homeMenuOffset - return ( - - ) - })} -
-
- )} - - {screen === 'roguelike' && ( -
- setScreen('menu')} - onBackPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'back'))} - /> -
- - -
- {roguelikeVariant === 'pve' && ( - <> -
-
-

Run Type

-

PvE Roguelike

-
-
- - -
-
-
- {roguelikeKind === 'raid' ? 'R' : 'D'} -
- {roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'} - - {roguelikeKind === 'raid' - ? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.' - : 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'} - -
- -
- - )} - {roguelikeVariant === 'pvp' && ( - <> -
-
-

Match Type

-

PvP Roguelike

-
-
- - - -
-
-
- {gameMode === 'offline' ? 'C' : 'Q'} -
- {gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'} - - {pvpContentType === 'stadium' - ? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.' - : gameMode === 'offline' - ? 'Offline mode always places you against a random CPU 1-5.' - : 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'} - -
- -
- {SHOW_LEADERBOARDS && ( -
-
-
-

CPU Leaderboard

-

{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

-
-
-
-
- Rank - Player - CPU - Clears - Result -
- {cpuLeaderboard.map((entry, index) => ( -
- #{index + 1} - {entry.characterName} - CPU {entry.cpuDifficulty} - {entry.encountersCleared} - {entry.result} -
- ))} - {cpuLeaderboard.length === 0 && ( -
- No CPU runs recorded yet for this mode. -
- )} -
-
- )} - - )} -
- )} - - {(screen === 'dungeons' || screen === 'raids') && ( -
-
-
-
-
-
-

Pick Run

-

{screen === 'raids' ? 'Raid' : 'Dungeon'}

-
-
- - {activityPageCount > 1 ? ( -
- - {activityPageStart}-{activityPageEnd} of {activityOptions.length} - -
- ) : ( - {selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components. - )} -
-
-
- {pagedActivityOptions.map((candidate, index) => { - const difficulty = candidate.difficulties.find( - (option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel, - ) ?? candidate.difficulties[0] - const locked = profile.character.level < difficulty.unlockLevel - const selected = candidate.id === activity.id - return ( - - ) - })} -
-
-
- - -
-
- )} - - }> - {screen === 'customize' && ( - setScreen('menu')} - onSaved={setProfile} - /> - )} - - {screen === 'talents' && ( - setScreen('menu')} - onUpdated={setProfile} - /> - )} - - {screen === 'equipment' && ( - setScreen('menu')} - onUpdated={setProfile} - /> - )} - - {screen === 'hunter-profile' && ( - setScreen('menu')} - /> - )} - - {screen === 'settings' && ( - setScreen('menu')} /> - )} - - -
- ) -} - -function ScreenHeading({ - backClassName = '', - backControllerSkip = false, - eyebrow, - title, - onBack, - onBackPointerDown, -}: { - backClassName?: string - backControllerSkip?: boolean - eyebrow: string - title: string - onBack: () => void - onBackPointerDown?: () => void -}) { - return ( -
-
-

{eyebrow}

-

{title}

-
- -
- ) -} - export default App diff --git a/src/components/AdminScreen.tsx b/src/components/AdminScreen.tsx index 68cae44..396a9ec 100644 --- a/src/components/AdminScreen.tsx +++ b/src/components/AdminScreen.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from 'react' import type { CSSProperties, Dispatch, SetStateAction } from 'react' +import { + IWT2_BOSS_METADATA, + type Iwt2BalanceOverrides, + type Iwt2BossId, + type Iwt2BossMetadata, +} from '../modes/iwt2/content/bosses' type AdminItem = { id: number @@ -130,7 +136,38 @@ type AdminData = { classes: AdminClass[] } -type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes' +type Iwt2AdminAsset = { + filename: string + size: number + url: string +} + +type Iwt2AdminData = { + balanceOverrides: Iwt2BalanceOverrides + bossAssets: Iwt2AdminAsset[] +} + +type Iwt2AdminAttack = { + name: string + frequency: string + damage: string + details: string +} + +type Iwt2AdminEntityRow = { + id: string + bossId: Iwt2BossId + kind: 'boss' | 'mob' + name: string + slug: string + icon: string + color: string + maxHealth: number + attacks: Iwt2AdminAttack[] + assets: Iwt2AdminAsset[] +} + +type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes' | 'iwt2' type SavingState = Record type SetData = Dispatch> type SetSaving = Dispatch> @@ -144,6 +181,7 @@ const tabs: { id: AdminTab; label: string }[] = [ { id: 'crafting', label: 'Crafting' }, { id: 'upgrades', label: 'Upgrades' }, { id: 'classes', label: 'Classes' }, + { id: 'iwt2', label: 'IWT2' }, ] async function fetchJson(url: string, init?: RequestInit): Promise { @@ -189,6 +227,7 @@ export function AdminScreen({ onBack }: { onBack: () => void }) { {tab === 'crafting' && } {tab === 'upgrades' && } {tab === 'classes' && } + {tab === 'iwt2' && }
) } @@ -1155,6 +1194,196 @@ function ClassesTab({ data }: { data: AdminData }) { ) } +function Iwt2AdminTab() { + const [metadata, setMetadata] = useState>(IWT2_BOSS_METADATA) + const [adminData, setAdminData] = useState(null) + const [error, setError] = useState('') + const [selectedEntityId, setSelectedEntityId] = useState('bulldrome') + const [draftHp, setDraftHp] = useState>({}) + const [saving, setSaving] = useState>({}) + + useEffect(() => { + fetchJson(`${API}/iwt2/data`) + .then((result) => { + setAdminData(result) + setMetadata(applyIwt2AdminOverrides(IWT2_BOSS_METADATA, result.balanceOverrides)) + }) + .catch((e: unknown) => { + setError(e instanceof Error ? e.message : 'Failed to load IWT2 admin data') + setAdminData({ balanceOverrides: { bosses: {} }, bossAssets: [] }) + }) + }, []) + + const rows = createIwt2AdminRows(metadata, adminData?.bossAssets ?? []) + const selectedRow = rows.find((row) => row.id === selectedEntityId) ?? rows[0] + + async function saveHp(row: Iwt2AdminEntityRow) { + const value = Number(draftHp[row.id] ?? row.maxHealth) + if (!Number.isFinite(value) || value <= 0) { + alert('HP must be greater than zero.') + return + } + const nextHealth = Math.round(value) + setSaving((prev) => ({ ...prev, [row.id]: true })) + try { + await fetchJson(`${API}/iwt2/entities/${encodeURIComponent(row.id)}/hp`, jsonRequest('PUT', { maxHealth: nextHealth })) + setMetadata((current) => { + const boss = current[row.bossId] + return { + ...current, + [row.bossId]: row.kind === 'boss' + ? { ...boss, maxHealth: nextHealth } + : { ...boss, birdHealth: nextHealth }, + } + }) + setDraftHp((prev) => { + const next = { ...prev } + delete next[row.id] + return next + }) + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving((prev) => ({ ...prev, [row.id]: false })) + } + } + + return ( +
+ {error &&

{error}

} +
+ {rows.length} mobs / bosses + {adminData?.bossAssets.length ?? 0} uploaded boss assets + HP edits write to IWT2 balance overrides +
+
+ + {selectedRow && ( + + )} +
+
+ ) +} + +function Iwt2AdminDetail({ + draftHp, + row, + saveHp, + saving, + setDraftHp, +}: { + draftHp: Record + row: Iwt2AdminEntityRow + saveHp: (row: Iwt2AdminEntityRow) => void + saving: Record + setDraftHp: Dispatch>> +}) { + const value = draftHp[row.id] ?? String(row.maxHealth) + const changed = Number(value) !== row.maxHealth + + return ( +
+
+ {row.icon} +
+

{row.kind}

+

{row.name}

+ {row.slug} · source boss: {row.bossId} +
+
+ + + +
+
+

Assets

+ {row.assets.length} uploaded +
+
+
+ {row.icon} + Runtime Token + Icon and color used by current arena renderer. +
+ {row.assets.length > 0 ? row.assets.map((asset) => { + const label = iwt2AssetLabel(asset.filename) + return ( + + {`${row.name} + {label} + {asset.filename} + + ) + }) : ( +

No uploaded raster asset matched. Runtime uses shape, icon, and color.

+ )} +
+
+ +
+
+

Attacks

+ {row.attacks.length} mechanics +
+
+
+ Attack + Frequency + Damage +
+ {row.attacks.map((attack) => ( +
+ {attack.name} + {attack.frequency} + {attack.damage} + {attack.details} +
+ ))} +
+
+
+ ) +} + function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit { return { method, @@ -1163,6 +1392,136 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit { } } +function applyIwt2AdminOverrides( + metadata: Record, + overrides: Iwt2BalanceOverrides, +): Record { + return { + bulldrome: { + ...metadata.bulldrome, + ...overrides.bosses?.bulldrome, + }, + 'yian-kut-ku': { + ...metadata['yian-kut-ku'], + ...overrides.bosses?.['yian-kut-ku'], + }, + } +} + +function createIwt2AdminRows( + metadata: Record, + bossAssets: Iwt2AdminAsset[], +): Iwt2AdminEntityRow[] { + const bosses = Object.values(metadata).map((boss): Iwt2AdminEntityRow => ({ + assets: assetsForIwt2Boss(boss.id, bossAssets), + attacks: bossAttacks(boss), + bossId: boss.id, + color: boss.color, + icon: boss.icon, + id: boss.id, + kind: 'boss', + maxHealth: boss.maxHealth, + name: boss.name, + slug: boss.id, + })) + const yian = metadata['yian-kut-ku'] + if (!yian.birdHealth) return bosses + return [ + ...bosses, + { + assets: [], + attacks: yianBirdAttacks(yian), + bossId: yian.id, + color: '#f0b84f', + icon: 'v', + id: `${yian.id}:yian-bird`, + kind: 'mob', + maxHealth: yian.birdHealth, + name: 'Yian Kut Ku Hatchling', + slug: 'yian-bird', + }, + ] +} + +function bossAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] { + const attacks: Iwt2AdminAttack[] = [{ + damage: `${boss.meleeDamage}`, + details: `Range ${boss.meleeRange}px`, + frequency: `${formatSeconds(boss.meleeCooldown)} cooldown`, + name: 'Melee', + }] + if (boss.chargeCooldown > 0) { + attacks.push({ + damage: `${boss.chargeDamage}`, + details: `${formatSeconds(boss.chargeWindup)} windup, ${formatSeconds(boss.chargeStunSeconds)} stun / knockdown`, + frequency: `${formatSeconds(boss.chargeCooldown)} cooldown`, + name: 'Charge', + }) + } + if (boss.slamDamage > 0) { + attacks.push({ + damage: `${boss.slamDamage}`, + details: `${boss.slamRadius}px radius, ${formatSeconds(boss.slamStunSeconds)} stun / knockdown`, + frequency: 'Every 3rd charge', + name: 'Ground Slam', + }) + } + if (boss.fireballCooldown && boss.fireballDamage) { + attacks.push({ + damage: `${boss.fireballDamage} hit + ${boss.firePuddleDamage ?? 0}/tick puddle`, + details: `${boss.firePuddleRadius ?? 0}px puddle for ${formatSeconds(boss.firePuddleSeconds ?? 0)}`, + frequency: `${formatSeconds(boss.fireballCooldown)} cooldown`, + name: 'Fireball', + }) + } + if (boss.birdWaveThresholds?.length) { + attacks.push({ + damage: `${boss.birdContactDamage ?? 0} contact`, + details: `${boss.birdWaveThresholds.map((threshold) => `${Math.round(threshold * 100)}%`).join(', ')} HP thresholds`, + frequency: 'Health threshold', + name: 'Bird Wave', + }) + } + return attacks +} + +function yianBirdAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] { + return [ + { + damage: `${boss.birdContactDamage ?? 0}`, + details: `Contact radius ${boss.birdRadius ?? 0}px`, + frequency: '1.15s cooldown', + name: 'Peck', + }, + { + damage: '10', + details: `${formatSeconds(boss.birdFlightWindup ?? 0)} windup, ${formatSeconds(boss.birdStunSeconds ?? 0)} stun`, + frequency: `${formatSeconds(boss.birdFlightCooldown ?? 0)} cooldown`, + name: 'Flight Lane', + }, + ] +} + +function assetsForIwt2Boss(bossId: Iwt2BossId, bossAssets: Iwt2AdminAsset[]): Iwt2AdminAsset[] { + const terms = bossId.split('-').filter((part) => part.length > 2) + return bossAssets.filter((asset) => { + const filename = asset.filename.toLowerCase() + return filename.includes(bossId) || terms.some((term) => filename.includes(term)) + }) +} + +function iwt2AssetLabel(filename: string) { + const normalized = filename.toLowerCase() + if (normalized.includes('approach')) return 'Approach Illustration' + if (normalized.includes('guardians')) return 'Party Encounter Art' + if (normalized.includes('boss')) return 'Boss Portrait' + return 'Primary Boss Art' +} + +function formatSeconds(value: number) { + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0$/, '')}s` +} + function fileToDataUrl(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader() diff --git a/src/components/PartyFrames.tsx b/src/components/PartyFrames.tsx index dc82a48..2e6f09e 100644 --- a/src/components/PartyFrames.tsx +++ b/src/components/PartyFrames.tsx @@ -109,7 +109,7 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({ ].filter(Boolean).join(' ') const action = targetBinding ? { binding: targetBinding, - iconStyle: controllerIconStyle ?? 'xbox', + iconStyle: controllerIconStyle ?? 'playstation', } : null return ( diff --git a/src/input.tsx b/src/input.tsx index e57528a..cc18776 100644 --- a/src/input.tsx +++ b/src/input.tsx @@ -13,6 +13,10 @@ import { Capacitor } from '@capacitor/core' export type InputDevice = 'pc' | 'controller' export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo' +export type MovementVector = { + x: number + y: number +} export const INPUT_ACTIONS = [ 'navigateUp', @@ -131,7 +135,7 @@ const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const GAME_ACTION_EVENT = 'ashen-halls-game-action' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])' -const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display' +const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display' const HEADER_CONTENT_SELECTOR = '.app-header' const GAMEPAD_COMBAT_POLL_MS = 1000 / 60 const GAMEPAD_MENU_POLL_MS = 1000 / 30 @@ -235,13 +239,13 @@ function loadPreferences() { combatTouchLocked?: boolean } return { - controllerIconStyle: saved.controllerIconStyle ?? 'xbox', + controllerIconStyle: saved.controllerIconStyle ?? 'playstation', directPartyTargeting: saved.directPartyTargeting ?? false, combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(), } } catch { return { - controllerIconStyle: 'xbox' as ControllerIconStyle, + controllerIconStyle: 'playstation' as ControllerIconStyle, directPartyTargeting: false, combatTouchLocked: Capacitor.isNativePlatform(), } @@ -530,6 +534,10 @@ function isCombatActive() { return Boolean(document.querySelector('[data-combat-active="true"]')) } +function hasContinuousMovementActive() { + return Boolean(document.querySelector('[data-continuous-movement-active="true"]')) +} + function firstConnectedGamepad() { return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null } @@ -686,6 +694,12 @@ export function InputProvider({ children }: { children: ReactNode }) { document.querySelector('[data-combat-active="true"]'), ) const uiOverlay = hasUiOverlay() + if ( + combatActive + && !uiOverlay + && hasContinuousMovementActive() + && (token.startsWith('Axis0') || token.startsWith('Axis1')) + ) return const uiPriority = [ 'navigateUp', 'navigateDown', @@ -1046,6 +1060,74 @@ export function InputProvider({ children }: { children: ReactNode }) { ) } +function normalizedVector(x: number, y: number): MovementVector { + const magnitude = Math.hypot(x, y) + if (magnitude <= 1) return { x, y } + return { x: x / magnitude, y: y / magnitude } +} + +function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector { + const magnitude = Math.hypot(x, y) + if (magnitude < deadzone) return { x: 0, y: 0 } + const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone)) + return { + x: (x / magnitude) * scaled, + y: (y / magnitude) * scaled, + } +} + +export function useMovementVectorRef(enabled = true) { + const movementRef = useRef({ x: 0, y: 0 }) + + useEffect(() => { + if (!enabled) { + movementRef.current = { x: 0, y: 0 } + return undefined + } + + let frame = 0 + const pressedKeys = new Set() + const keyboardVector = () => normalizedVector( + (pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0), + (pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0), + ) + const updateMovement = () => { + const gamepad = firstConnectedGamepad() + const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 } + const keyboard = keyboardVector() + movementRef.current = Math.hypot(stick.x, stick.y) > 0 + ? stick + : keyboard + frame = window.requestAnimationFrame(updateMovement) + } + const onKeyDown = (event: KeyboardEvent) => { + if (isTextInput(document.activeElement)) return + if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return + pressedKeys.add(event.code) + document.documentElement.dataset.inputDevice = 'pc' + event.preventDefault() + } + const onKeyUp = (event: KeyboardEvent) => { + if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return + pressedKeys.delete(event.code) + event.preventDefault() + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + frame = window.requestAnimationFrame(updateMovement) + + return () => { + window.cancelAnimationFrame(frame) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + movementRef.current = { x: 0, y: 0 } + } + }, [enabled]) + + return movementRef +} + export function useInput() { const context = useContext(InputContext) if (!context) throw new Error('useInput must be used inside InputProvider') diff --git a/src/main.tsx b/src/main.tsx index 9924c37..ea159f9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,13 +2,21 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { Capacitor } from '@capacitor/core' import './index.css' +import './App.css' import App from './App.tsx' import { InputProvider } from './input.tsx' import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx' +import { Iwt2BottomDisplay } from './modes/iwt2/Iwt2BottomDisplay.tsx' + +const displayMode = new URLSearchParams(window.location.search).get('display') createRoot(document.getElementById('root')!).render( - {new URLSearchParams(window.location.search).get('display') === 'bottom' ? ( + {displayMode === 'iwt2-bottom' ? ( + + + + ) : displayMode === 'bottom' ? ( diff --git a/src/modes/iwt1/IWantToHeal1App.tsx b/src/modes/iwt1/IWantToHeal1App.tsx new file mode 100644 index 0000000..e7a0cd1 --- /dev/null +++ b/src/modes/iwt1/IWantToHeal1App.tsx @@ -0,0 +1,1624 @@ +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' +import { AuthScreen } from '../../components/AuthScreen' +import { + loadCpuPvpLeaderboard, + type CpuPvpLeaderboardEntry, + type PvpContentType, +} from '../../pvpRoguelike' +import { + loadAuthSession, + logoutAccount, + type Account, + type AuthSession, + type CharacterProfile, +} from '../../profile' +import { + applyCloudSaveSync, + getCloudSyncStatus, + getGameMode, + previewCloudSaveSync, + type CloudSyncChoice, + type CloudSyncComparison, + type GameMode, +} from '../../gameRepository' +import { focusFirstControl, useGameAction } from '../../input.tsx' +import { + useDualScreen, + useDualScreenSetupPublisher, + type DualScreenSetupState, +} from '../../dualScreen' +import { barFillStyle } from '../../components/barStyles' + +const CombatScreen = lazy(() => import('../../components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) +const CustomizeScreen = lazy(() => import('../../components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen }))) +const EquipmentScreen = lazy(() => import('../../components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen }))) +const HunterProfileScreen = lazy(() => import('../../components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen }))) +const PvPRoguelikeScreen = lazy(() => import('../../components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen }))) +const PvpStadiumScreen = lazy(() => import('../../components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen }))) +const TalentScreen = lazy(() => import('../../components/TalentScreen').then((module) => ({ default: module.TalentScreen }))) +const SettingsScreen = lazy(() => import('../../components/SettingsScreen').then((module) => ({ default: module.SettingsScreen }))) + +type Screen = + | 'menu' + | 'dungeons' + | 'combat' + | 'raids' + | 'roguelike' + | 'pvp' + | 'customize' + | 'equipment' + | 'hunter-profile' + | 'talents' + | 'settings' + +const MENU_ITEMS: Array<{ + screen: Screen + label: string + glyph: string + description: string +}> = [ + { screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' }, + { screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' }, + { screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' }, + { screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' }, + { screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' }, + { screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' }, + { screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' }, +] + +const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty' +const SHOW_LEADERBOARDS = false +const ACTIVITY_PAGE_SIZE = 4 +const HOME_MENU_COLUMNS = 2 +const DUNGEON_NAV_COLUMNS = 2 + +type DungeonNavEntry = + | { kind: 'spacer'; disabled: true } + | { kind: 'back'; disabled?: boolean } + | { kind: 'pagePrev'; disabled?: boolean } + | { kind: 'pageNext'; disabled?: boolean } + | { kind: 'activity'; index: number; disabled?: boolean } + | { kind: 'tier'; index: number; disabled?: boolean } + | { kind: 'start'; disabled?: boolean } + | { kind: 'marathon'; disabled?: boolean } + | { kind: 'loot'; disabled?: boolean } + | { kind: 'lootSort'; disabled?: boolean } + +type RoguelikeNavEntry = + | { kind: 'back' } + | { kind: 'variantPve' } + | { kind: 'variantPvp' } + | { kind: 'pveDungeon' } + | { kind: 'pveRaid' } + | { kind: 'pveStart' } + | { kind: 'pvpDungeon' } + | { kind: 'pvpRaid' } + | { kind: 'pvpStadium' } + | { kind: 'pvpStart' } + +type RoguelikeNavPosition = { + row: number + column: number +} + +function formatSaveTimestamp(updatedAt: number | null) { + if (!updatedAt) return 'Unknown legacy timestamp' + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(updatedAt)) +} + +function cloudSyncRelationText(comparison: CloudSyncComparison) { + if (comparison.relation === 'server-newer') return 'Server save is newer than this device.' + if (comparison.relation === 'local-newer') return 'This device save is newer than the server.' + if (comparison.relation === 'same') return 'Server and this device have the same save time.' + return 'One save has no timestamp yet. Choose the copy you want to keep.' +} + +function activityInitials(name: string) { + return name + .split(/\s+/) + .filter((word) => /^[A-Za-z0-9]/.test(word)) + .slice(0, 2) + .map((word) => word[0].toUpperCase()) + .join('') +} + +function ScreenLoading() { + return ( +
+
+

Opening Chronicle

+

Loading...

+
+
+ ) +} + +type RoguelikeVariant = 'pve' | 'pvp' + +function IWantToHeal1App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) { + const { enabled: dualScreenEnabled } = useDualScreen() + const [screen, setScreen] = useState('menu') + const [account, setAccount] = useState(null) + const [profile, setProfile] = useState(null) + const [authChecked, setAuthChecked] = useState(false) + const [gameMode, setGameMode] = useState(getGameMode()) + const [serverMessage, setServerMessage] = useState('') + const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => { + const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY)) + return Number.isFinite(saved) && saved > 0 ? saved : 1 + }) + const [selectedDungeonId, setSelectedDungeonId] = useState(1) + const [selectedRaidId, setSelectedRaidId] = useState(20) + const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon') + const [roguelikeVariant, setRoguelikeVariant] = useState('pve') + const [pvpContentType, setPvpContentType] = useState('dungeon') + const [selectedMarathonMode, setSelectedMarathonMode] = useState(false) + const [activityPage, setActivityPage] = useState(0) + const [combatContentId, setCombatContentId] = useState(1) + const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') + const [showLoot, setShowLoot] = useState(false) + const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence') + const [showLeaderboard, setShowLeaderboard] = useState(false) + const [error, setError] = useState('') + const [syncingCloud, setSyncingCloud] = useState(false) + const [syncMessage, setSyncMessage] = useState('') + const [syncComparison, setSyncComparison] = useState(null) + const [homeSelectedIndex, setHomeSelectedIndex] = useState(0) + const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0) + const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1) + + useEffect(() => { + loadAuthSession() + .then((session) => { + setAccount(session.account) + setProfile(session.profile) + }) + .catch((reason: unknown) => { + setServerMessage( + reason instanceof Error + ? `${reason.message} Offline play is still available.` + : 'Unable to reach the server. Offline play is still available.', + ) + }) + .finally(() => setAuthChecked(true)) + }, []) + + useEffect(() => { + const handleModeChange = (event: Event) => { + const nextMode = (event as CustomEvent).detail + setGameMode(nextMode) + } + window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener) + return () => { + window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener) + } + }, []) + + useEffect(() => { + if (screen === 'combat') return + window.requestAnimationFrame(() => { + focusFirstControl() + }) + }, [screen]) + + useEffect(() => { + if (!authChecked || !account || !profile || screen === 'combat') return + window.requestAnimationFrame(() => { + focusFirstControl() + }) + }, [account, authChecked, profile, screen]) + + useEffect(() => { + window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId)) + }, [selectedDifficultyId]) + + const [cpuLeaderboard, setCpuLeaderboard] = useState([]) + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + }) + return () => window.cancelAnimationFrame(frame) + }, [pvpContentType, screen, roguelikeVariant]) + + const profileDungeons = profile?.dungeons + const dungeonOptions = useMemo( + () => profileDungeons?.filter((candidate) => candidate.contentType === 'dungeon') ?? [], + [profileDungeons], + ) + const raidOptions = useMemo( + () => profileDungeons?.filter((candidate) => candidate.contentType === 'raid') ?? [], + [profileDungeons], + ) + const selectedDungeonOption = useMemo( + () => dungeonOptions.find((candidate) => candidate.id === selectedDungeonId) ?? dungeonOptions[0], + [dungeonOptions, selectedDungeonId], + ) + const selectedRaidOption = useMemo( + () => raidOptions.find((candidate) => candidate.id === selectedRaidId) ?? raidOptions[0], + [raidOptions, selectedRaidId], + ) + const activityOptions = useMemo( + () => screen === 'raids' ? raidOptions : dungeonOptions, + [dungeonOptions, raidOptions, screen], + ) + const combatDungeonOption = useMemo( + () => { + if (!profileDungeons) return undefined + return combatContentId < 0 + ? profileDungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profileDungeons[0] + : profileDungeons.find((candidate) => candidate.id === combatContentId) ?? profileDungeons[0] + }, + [combatContentId, profileDungeons, roguelikeKind], + ) + const combatDifficultyOption = useMemo( + () => combatDungeonOption?.difficulties.find( + (candidate) => candidate.id === selectedDifficultyId, + ) ?? combatDungeonOption?.difficulties[0], + [combatDungeonOption, selectedDifficultyId], + ) + const roguelikePool = useMemo( + () => profileDungeons + ?.filter((candidate) => candidate.contentType === roguelikeKind) + .flatMap((candidate) => candidate.encounters) ?? [], + [profileDungeons, roguelikeKind], + ) + const pvpPool = useMemo( + () => profileDungeons + ?.filter((candidate) => candidate.contentType === pvpContentType) + .flatMap((candidate) => candidate.encounters) ?? [], + [profileDungeons, pvpContentType], + ) + const tierOptions = useMemo( + () => activityOptions + .flatMap((option) => option.difficulties) + .filter((difficulty, index, all) => ( + all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index + )) + .sort((a, b) => a.droppedItemLevel - b.droppedItemLevel), + [activityOptions], + ) + const savedDifficulty = useMemo( + () => profileDungeons + ?.flatMap((option) => option.difficulties) + .find((candidate) => candidate.id === selectedDifficultyId), + [profileDungeons, selectedDifficultyId], + ) + const selectedTier = useMemo( + () => { + const characterLevel = profile?.character.level ?? 0 + const savedTier = tierOptions.find((candidate) => ( + candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel + && characterLevel >= candidate.unlockLevel + )) + if (savedTier) return savedTier + for (let index = tierOptions.length - 1; index >= 0; index -= 1) { + if (characterLevel >= tierOptions[index].unlockLevel) return tierOptions[index] + } + return tierOptions[0] + }, + [profile?.character.level, savedDifficulty?.droppedItemLevel, tierOptions], + ) + const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0 + const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE)) + const currentActivityPage = Math.min(activityPage, activityPageCount - 1) + const pagedActivityOptions = useMemo( + () => activityOptions.slice( + currentActivityPage * ACTIVITY_PAGE_SIZE, + currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE, + ), + [activityOptions, currentActivityPage], + ) + const selectedActivityOption = useMemo( + () => { + const fallback = screen === 'raids' && selectedRaidOption ? selectedRaidOption : selectedDungeonOption + const selectedActivityId = screen === 'raids' && selectedRaidOption ? selectedRaidOption.id : selectedDungeonOption?.id + return activityOptions.find((candidate) => candidate.id === selectedActivityId) + ?? activityOptions[0] + ?? fallback + }, + [activityOptions, screen, selectedDungeonOption, selectedRaidOption], + ) + const selectedDifficultyOption = useMemo( + () => selectedActivityOption?.difficulties.find( + (candidate) => candidate.droppedItemLevel === selectedTierItemLevel, + ) ?? selectedActivityOption?.difficulties[0], + [selectedActivityOption, selectedTierItemLevel], + ) + const lootPreviewEncounters = useMemo( + () => [...(selectedActivityOption?.encounters ?? [])] + .filter((encounter) => encounter.isBoss) + .sort((a, b) => lootSort === 'boss' + ? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence + : a.sequence - b.sequence), + [lootSort, selectedActivityOption?.encounters], + ) + const lootPreviewByEncounterId = useMemo( + () => new Map(lootPreviewEncounters.map((encounter) => [ + encounter.id, + encounter.lootTables.filter((entry) => entry.difficultyId === selectedDifficultyOption?.id), + ])), + [lootPreviewEncounters, selectedDifficultyOption?.id], + ) + const leaderboardEntries = useMemo( + () => selectedActivityOption?.leaderboards[leaderboardCategory].filter( + (entry) => entry.difficultyId === selectedDifficultyOption?.id, + ) ?? [], + [leaderboardCategory, selectedActivityOption?.leaderboards, selectedDifficultyOption?.id], + ) + + function acceptSession(session: AuthSession) { + setAccount(session.account) + setProfile(session.profile) + setGameMode(getGameMode()) + setScreen('menu') + setError('') + setServerMessage('') + window.requestAnimationFrame(() => { + focusFirstControl() + }) + } + + async function signOut() { + try { + await logoutAccount() + setAccount(null) + setProfile(null) + setGameMode(getGameMode()) + setScreen('menu') + setSyncMessage('') + setSyncComparison(null) + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to sign out.') + } + } + + async function syncSaveNow() { + setSyncingCloud(true) + setSyncMessage('') + setSyncComparison(null) + try { + const comparison = await previewCloudSaveSync() + setSyncComparison(comparison) + setSyncMessage('') + } catch (reason) { + setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') + } finally { + setSyncingCloud(false) + } + } + + async function keepCloudSave(choice: CloudSyncChoice) { + setSyncingCloud(true) + setSyncMessage(choice === 'local' ? 'Uploading this device save...' : 'Downloading server save...') + try { + const updated = await applyCloudSaveSync(choice) + setProfile(updated) + setGameMode(getGameMode()) + setSyncComparison(null) + setSyncMessage(choice === 'local' + ? 'Server now uses this device save.' + : 'This device now uses the server save.') + } catch (reason) { + setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') + } finally { + setSyncingCloud(false) + } + } + + const cloudSync = getCloudSyncStatus() + const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available) + const cloudSyncEntryCount = canShowCloudSync ? (syncComparison ? 3 : 1) : 0 + const homeMenuOffset = cloudSyncEntryCount + const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset + const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1) + + function openHomeMenuIndex(index: number) { + if (canShowCloudSync && index === 0) { + if (!syncingCloud) void syncSaveNow() + return + } + if (canShowCloudSync && syncComparison && index === 1) { + if (!syncingCloud) void keepCloudSave('local') + return + } + if (canShowCloudSync && syncComparison && index === 2) { + if (!syncingCloud) void keepCloudSave('server') + return + } + const item = MENU_ITEMS[index - homeMenuOffset] + if (!item) return + if (item.screen === 'pvp') { + setRoguelikeVariant('pvp') + setRoguelikeSelectedIndex(2) + setScreen('roguelike') + return + } + if (item.screen === 'roguelike') { + setRoguelikeSelectedIndex(1) + } + if (item.screen === 'dungeons' || item.screen === 'raids') { + const nextOptions = item.screen === 'raids' ? raidOptions : dungeonOptions + setActivityPage(0) + setDungeonSelectedIndex(firstActivityDungeonEntryIndexForPageCount( + Math.max(1, Math.ceil(nextOptions.length / ACTIVITY_PAGE_SIZE)), + )) + } + setScreen(item.screen) + } + + function moveHomeSelection(action: string) { + setHomeSelectedIndex((current) => { + const bounded = Math.min(current, homeMenuEntryCount - 1) + if (canShowCloudSync && syncComparison && bounded <= 2) { + if (bounded === 0) { + return action === 'navigateDown' || action === 'navigateRight' ? 1 : 0 + } + if (bounded === 1) { + if (action === 'navigateRight') return 2 + if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1) + return 0 + } + if (action === 'navigateLeft') return 1 + if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1) + return 0 + } + if (canShowCloudSync && !syncComparison && bounded === 0) { + return action === 'navigateDown' || action === 'navigateRight' + ? Math.min(homeMenuOffset, homeMenuEntryCount - 1) + : 0 + } + + const menuIndex = bounded - homeMenuOffset + if (menuIndex < 0) return bounded + const column = menuIndex % HOME_MENU_COLUMNS + if (action === 'navigateLeft') { + if (column > 0) return bounded - 1 + if (canShowCloudSync && syncComparison && menuIndex < HOME_MENU_COLUMNS) return 2 + return bounded + } + if (action === 'navigateRight') { + const nextMenuIndex = menuIndex + 1 + return column < HOME_MENU_COLUMNS - 1 && nextMenuIndex < MENU_ITEMS.length ? bounded + 1 : bounded + } + if (action === 'navigateUp') { + const previousMenuIndex = menuIndex - HOME_MENU_COLUMNS + if (previousMenuIndex >= 0) return homeMenuOffset + previousMenuIndex + if (canShowCloudSync && syncComparison) return column === 0 ? 1 : 2 + if (canShowCloudSync) return 0 + return bounded + } + const nextMenuIndex = menuIndex + HOME_MENU_COLUMNS + return nextMenuIndex < MENU_ITEMS.length ? homeMenuOffset + nextMenuIndex : bounded + }) + } + + function dungeonEntries() { + const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0] + const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true + const entries: DungeonNavEntry[] = [ + { kind: 'back' }, + ] + if (activityPageCount > 1) { + entries.push( + { kind: 'pagePrev', disabled: currentActivityPage === 0 }, + { kind: 'pageNext', disabled: currentActivityPage >= activityPageCount - 1 }, + ) + } + if (entries.length % DUNGEON_NAV_COLUMNS !== 0) { + entries.push({ kind: 'spacer', disabled: true }) + } + pagedActivityOptions.forEach((candidate, index) => { + const candidateDifficulty = difficulty + ? candidate.difficulties.find( + (option) => option.droppedItemLevel === difficulty.droppedItemLevel, + ) ?? candidate.difficulties[0] + : candidate.difficulties[0] + entries.push({ + kind: 'activity', + index, + disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel, + }) + }) + entries.push( + { kind: 'start', disabled: locked }, + { kind: 'marathon', disabled: locked }, + ) + tierOptions.forEach((difficultyOption, index) => { + entries.push({ + kind: 'tier', + index, + disabled: !profile || profile.character.level < difficultyOption.unlockLevel, + }) + }) + entries.push({ kind: 'loot' }) + if (showLoot) entries.push({ kind: 'lootSort' }) + return entries + } + + function firstEnabledDungeonEntry(entries: DungeonNavEntry[]) { + return Math.max(0, entries.findIndex((entry) => !entry.disabled)) + } + + function firstActivityDungeonEntryIndexForPageCount(pageCount: number) { + const entryCountBeforeActivities = pageCount > 1 ? 3 : 1 + return entryCountBeforeActivities % DUNGEON_NAV_COLUMNS === 0 + ? entryCountBeforeActivities + : entryCountBeforeActivities + 1 + } + + function activeDungeonEntry(entries = dungeonEntries()) { + if (entries[dungeonSelectedIndex] && !entries[dungeonSelectedIndex].disabled) { + return entries[dungeonSelectedIndex] + } + const firstEnabled = firstEnabledDungeonEntry(entries) + return entries[firstEnabled] ?? entries[0] + } + + function dungeonEntrySelected(entry: DungeonNavEntry['kind'], index?: number) { + const active = activeDungeonEntry() + return active?.kind === entry && ('index' in active ? active.index === index : index === undefined) + } + + function moveDungeonSelection(action: string) { + const entries = dungeonEntries() + if (entries.length === 0) return + setDungeonSelectedIndex((current) => { + const bounded = entries[current] && !entries[current].disabled + ? current + : firstEnabledDungeonEntry(entries) + const column = bounded % DUNGEON_NAV_COLUMNS + const direction = action === 'navigateLeft' || action === 'navigateUp' ? -1 : 1 + const step = action === 'navigateUp' || action === 'navigateDown' ? DUNGEON_NAV_COLUMNS : 1 + if (action === 'navigateLeft' && column === 0) return bounded + if (action === 'navigateRight' && column === DUNGEON_NAV_COLUMNS - 1) return bounded + for (let next = bounded + direction * step; next >= 0 && next < entries.length; next += direction * step) { + if (!entries[next].disabled) return next + } + return bounded + }) + } + + function selectActivityByPageIndex(index: number) { + const candidate = pagedActivityOptions[index] + if (!candidate) return + const difficulty = selectedDifficultyOption + ? candidate.difficulties.find( + (option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel, + ) ?? candidate.difficulties[0] + : candidate.difficulties[0] + if (profile && profile.character.level < difficulty.unlockLevel) return + if (screen === 'raids') setSelectedRaidId(candidate.id) + else setSelectedDungeonId(candidate.id) + setSelectedDifficultyId(difficulty.id) + } + + function selectTierByIndex(index: number) { + const difficulty = tierOptions[index] + const activity = selectedActivityOption ?? activityOptions[0] + if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return + setActivityPage(0) + const nextActivity = activity.difficulties.some( + (candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel, + ) + ? activity + : activityOptions.find((option) => + option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel), + ) + if (!nextActivity) return + if (screen === 'raids') setSelectedRaidId(nextActivity.id) + else setSelectedDungeonId(nextActivity.id) + const nextDifficulty = nextActivity.difficulties.find( + (candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel, + ) + if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id) + } + + function startSelectedRun(marathon: boolean) { + const activity = selectedActivityOption ?? activityOptions[0] + const difficulty = selectedDifficultyOption ?? activity?.difficulties[0] + if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return + setSelectedMarathonMode(marathon) + setCombatContentId(activity.id) + setSelectedDifficultyId(difficulty.id) + setScreen('combat') + } + + function openDungeonEntry(entry: DungeonNavEntry | undefined) { + if (!entry || entry.disabled) return + if (entry.kind === 'back') setScreen('menu') + else if (entry.kind === 'pagePrev') setActivityPage((page) => Math.max(0, page - 1)) + else if (entry.kind === 'pageNext') setActivityPage((page) => Math.min(activityPageCount - 1, page + 1)) + else if (entry.kind === 'activity') selectActivityByPageIndex(entry.index) + else if (entry.kind === 'tier') selectTierByIndex(entry.index) + else if (entry.kind === 'start') startSelectedRun(false) + else if (entry.kind === 'marathon') startSelectedRun(true) + else if (entry.kind === 'loot') setShowLoot((current) => !current) + else if (entry.kind === 'lootSort') setLootSort((current) => current === 'sequence' ? 'boss' : 'sequence') + } + + function startPveRoguelike() { + const baseDungeon = dungeonOptions[0] + const baseRaid = raidOptions[0] + if (roguelikeKind === 'raid') { + setCombatContentId(-2) + setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101) + } else { + setCombatContentId(-1) + setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1) + } + setSelectedMarathonMode(false) + setScreen('combat') + } + + function roguelikeEntries() { + const entries: RoguelikeNavEntry[] = [ + { kind: 'back' }, + { kind: 'variantPve' }, + { kind: 'variantPvp' }, + ] + if (roguelikeVariant === 'pve') { + entries.push( + { kind: 'pveDungeon' }, + { kind: 'pveRaid' }, + { kind: 'pveStart' }, + ) + } else { + entries.push( + { kind: 'pvpDungeon' }, + { kind: 'pvpRaid' }, + { kind: 'pvpStadium' }, + { kind: 'pvpStart' }, + ) + } + return entries + } + + function activeRoguelikeEntry(entries = roguelikeEntries()) { + return entries[Math.min(roguelikeSelectedIndex, entries.length - 1)] ?? entries[0] + } + + function roguelikeEntrySelected(kind: RoguelikeNavEntry['kind']) { + return activeRoguelikeEntry()?.kind === kind + } + + function roguelikeEntryPosition(entry: RoguelikeNavEntry): RoguelikeNavPosition { + if (entry.kind === 'back') return { row: 0, column: 0 } + if (entry.kind === 'variantPve') return { row: 1, column: 0 } + if (entry.kind === 'variantPvp') return { row: 1, column: 1 } + if (entry.kind === 'pveDungeon' || entry.kind === 'pvpDungeon') return { row: 2, column: 0 } + if (entry.kind === 'pveRaid' || entry.kind === 'pvpRaid') return { row: 2, column: 1 } + if (entry.kind === 'pvpStart') return { row: 3, column: 1 } + return { row: 3, column: 0 } + } + + function moveRoguelikeSelection(action: string) { + const entries = roguelikeEntries() + setRoguelikeSelectedIndex((current) => { + const bounded = Math.min(current, entries.length - 1) + const active = entries[bounded] + if (!active) return bounded + const activePosition = roguelikeEntryPosition(active) + const candidates = entries + .map((entry, index) => ({ entry, index, position: roguelikeEntryPosition(entry) })) + .filter(({ index, position }) => { + if (index === bounded) return false + if (action === 'navigateLeft') return position.row === activePosition.row && position.column < activePosition.column + if (action === 'navigateRight') return position.row === activePosition.row && position.column > activePosition.column + if (action === 'navigateUp') return position.row < activePosition.row + return position.row > activePosition.row + }) + if (candidates.length === 0) return bounded + candidates.sort((a, b) => { + const aPrimary = Math.abs(a.position.row - activePosition.row) + + Math.abs(a.position.column - activePosition.column) + const bPrimary = Math.abs(b.position.row - activePosition.row) + + Math.abs(b.position.column - activePosition.column) + const aSecondary = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(a.position.column - activePosition.column) + : Math.abs(a.position.row - activePosition.row) + const bSecondary = action === 'navigateLeft' || action === 'navigateRight' + ? Math.abs(b.position.column - activePosition.column) + : Math.abs(b.position.row - activePosition.row) + return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index + }) + return candidates[0]?.index ?? bounded + }) + } + + function openRoguelikeEntry(entry: RoguelikeNavEntry | undefined) { + if (!entry) return + if (entry.kind === 'back') setScreen('menu') + else if (entry.kind === 'variantPve') setRoguelikeVariant('pve') + else if (entry.kind === 'variantPvp') setRoguelikeVariant('pvp') + else if (entry.kind === 'pveDungeon') setRoguelikeKind('dungeon') + else if (entry.kind === 'pveRaid') setRoguelikeKind('raid') + else if (entry.kind === 'pveStart') startPveRoguelike() + else if (entry.kind === 'pvpDungeon') setPvpContentType('dungeon') + else if (entry.kind === 'pvpRaid') setPvpContentType('raid') + else if (entry.kind === 'pvpStadium') setPvpContentType('stadium') + else if (entry.kind === 'pvpStart') setScreen('pvp') + } + + useGameAction((action, device) => { + if (device !== 'controller') return + if (screen === 'menu') { + if (action === 'confirm') { + openHomeMenuIndex(homeActiveIndex) + return + } + if (!action.startsWith('navigate')) return + moveHomeSelection(action) + return + } + if (screen === 'dungeons' || screen === 'raids') { + if (action === 'back') { + setScreen('menu') + return + } + if (action === 'confirm') { + openDungeonEntry(activeDungeonEntry()) + return + } + if (action.startsWith('navigate')) moveDungeonSelection(action) + } + if (screen === 'roguelike') { + if (action === 'back') { + setScreen('menu') + return + } + if (action === 'confirm') { + openRoguelikeEntry(activeRoguelikeEntry()) + return + } + if (action.startsWith('navigate')) moveRoguelikeSelection(action) + } + }) + + const setupDualScreenState = useMemo(() => { + if ( + !(screen === 'dungeons' || screen === 'raids') + || !profile + || !selectedActivityOption + || !selectedDifficultyOption + ) return null + const locked = profile.character.level < selectedDifficultyOption.unlockLevel + return { + contentType: selectedActivityOption.contentType, + title: selectedActivityOption.name, + subtitle: `${selectedActivityOption.locationName} | Level ${selectedActivityOption.recommendedLevel} | ${selectedActivityOption.partySize} Players`, + description: selectedActivityOption.description, + initials: activityInitials(selectedActivityOption.name), + difficultyName: selectedDifficultyOption.name, + itemLevel: selectedDifficultyOption.droppedItemLevel, + experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier), + lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined, + stats: { + health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`, + damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`, + xp: `${selectedDifficultyOption.experienceMultiplier.toFixed(1)}x`, + loot: `iLvl ${selectedDifficultyOption.droppedItemLevel}`, + }, + } + }, [profile, screen, selectedActivityOption, selectedDifficultyOption]) + + useDualScreenSetupPublisher(setupDualScreenState, dualScreenEnabled) + + if (error) { + return ( +
+
+

Database Error

+

Character Unavailable

+

{error}

+
+
+ ) + } + + if (!authChecked) { + return ( +
+
+

Opening Chronicle

+

Loading...

+
+
+ ) + } + + if (!account || !profile) { + return ( + + ) + } + + if (screen === 'combat') { + const dungeon = combatDungeonOption ?? profile.dungeons[0] + const difficulty = combatDifficultyOption ?? dungeon.difficulties[0] + return ( + }> + 0} + profile={profile} + roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} + roguelikeUpgradeTiming={combatContentId < 0 ? 'encounter' : undefined} + roguelikeAbilityLabelMode={combatContentId < 0 ? 'ability' : undefined} + roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined} + startPart={1} + onExit={() => { + setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons') + }} + onProfileUpdated={setProfile} + /> + + ) + } + + if (screen === 'pvp') { + if (pvpContentType === 'stadium') { + return ( + }> + { + setRoguelikeVariant('pvp') + setScreen('roguelike') + }} + onProfileUpdated={setProfile} + profile={profile} + /> + + ) + } + return ( + }> + { + setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType)) + setRoguelikeVariant('pvp') + setScreen('roguelike') + }} + onProfileUpdated={setProfile} + profile={profile} + /> + + ) + } + + const levelStart = profile.character.currentLevelExperience + const levelEnd = profile.character.nextLevelExperience + const experienceIntoLevel = profile.character.experience - levelStart + const experienceForLevel = Math.max(1, levelEnd - levelStart) + const experiencePercent = profile.character.level >= profile.maxLevel + ? 100 + : Math.min(100, (experienceIntoLevel / experienceForLevel) * 100) + const dungeon = selectedDungeonOption ?? profile.dungeons[0]! + const activityPageStart = activityOptions.length === 0 + ? 0 + : currentActivityPage * ACTIVITY_PAGE_SIZE + 1 + const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE) + const activity = selectedActivityOption ?? dungeon + const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0] + const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel + return ( +
+ {screen !== 'hunter-profile' && ( +
+ + +
+ {profile.character.name} + Level {profile.character.level} + Item Level {profile.gearStats.averageItemLevel.toFixed(1)} +
+ +
+ +
+
+ )} + + {screen === 'menu' && ( +
+
+ {canShowCloudSync && ( +
setHomeSelectedIndex(0)} + > + {syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'} +
+ Sync With Server + + {syncComparison + ? cloudSyncRelationText(syncComparison) + : cloudSync.dirty + ? 'Local progress waiting. Upload when you want to refresh the server copy.' + : 'Server copy matches this device.'} + + {syncComparison && ( +
+ Device: {formatSaveTimestamp(syncComparison.local.updatedAt)} + Server: {formatSaveTimestamp(syncComparison.server.updatedAt)} +
+ )} + {syncMessage && {syncMessage}} +
+
+ + {syncComparison && ( +
+ + +
+ )} +
+
+ )} + {MENU_ITEMS.map((item, index) => { + const homeIndex = index + homeMenuOffset + return ( + + ) + })} +
+
+ )} + + {screen === 'roguelike' && ( +
+ setScreen('menu')} + onBackPointerDown={() => setRoguelikeSelectedIndex(roguelikeEntries().findIndex((entry) => entry.kind === 'back'))} + /> +
+ + +
+ {roguelikeVariant === 'pve' && ( + <> +
+
+

Run Type

+

PvE Roguelike

+
+
+ + +
+
+
+ {roguelikeKind === 'raid' ? 'R' : 'D'} +
+ {roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'} + + {roguelikeKind === 'raid' + ? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.' + : 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'} + +
+ +
+ + )} + {roguelikeVariant === 'pvp' && ( + <> +
+
+

Match Type

+

PvP Roguelike

+
+
+ + + +
+
+
+ {gameMode === 'offline' ? 'C' : 'Q'} +
+ {gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'} + + {pvpContentType === 'stadium' + ? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.' + : gameMode === 'offline' + ? 'Offline mode always places you against a random CPU 1-5.' + : 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'} + +
+ +
+ {SHOW_LEADERBOARDS && ( +
+
+
+

CPU Leaderboard

+

{pvpContentType === 'stadium' ? 'Stadium' : pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

+
+
+
+
+ Rank + Player + CPU + Clears + Result +
+ {cpuLeaderboard.map((entry, index) => ( +
+ #{index + 1} + {entry.characterName} + CPU {entry.cpuDifficulty} + {entry.encountersCleared} + {entry.result} +
+ ))} + {cpuLeaderboard.length === 0 && ( +
+ No CPU runs recorded yet for this mode. +
+ )} +
+
+ )} + + )} +
+ )} + + {(screen === 'dungeons' || screen === 'raids') && ( +
+
+
+
+
+
+

Pick Run

+

{screen === 'raids' ? 'Raid' : 'Dungeon'}

+
+
+ + {activityPageCount > 1 ? ( +
+ + {activityPageStart}-{activityPageEnd} of {activityOptions.length} + +
+ ) : ( + {selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components. + )} +
+
+
+ {pagedActivityOptions.map((candidate, index) => { + const difficulty = candidate.difficulties.find( + (option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel, + ) ?? candidate.difficulties[0] + const locked = profile.character.level < difficulty.unlockLevel + const selected = candidate.id === activity.id + return ( + + ) + })} +
+
+
+ + +
+
+ )} + + }> + {screen === 'customize' && ( + setScreen('menu')} + onSaved={setProfile} + /> + )} + + {screen === 'talents' && ( + setScreen('menu')} + onUpdated={setProfile} + /> + )} + + {screen === 'equipment' && ( + setScreen('menu')} + onUpdated={setProfile} + /> + )} + + {screen === 'hunter-profile' && ( + setScreen('menu')} + /> + )} + + {screen === 'settings' && ( + setScreen('menu')} /> + )} + + +
+ ) +} + +function ScreenHeading({ + backClassName = '', + backControllerSkip = false, + eyebrow, + title, + onBack, + onBackPointerDown, +}: { + backClassName?: string + backControllerSkip?: boolean + eyebrow: string + title: string + onBack: () => void + onBackPointerDown?: () => void +}) { + return ( +
+
+

{eyebrow}

+

{title}

+
+ +
+ ) +} + +export default IWantToHeal1App diff --git a/src/modes/iwt2/IWantToHeal2App.tsx b/src/modes/iwt2/IWantToHeal2App.tsx new file mode 100644 index 0000000..070549c --- /dev/null +++ b/src/modes/iwt2/IWantToHeal2App.tsx @@ -0,0 +1,243 @@ +import { useEffect, useState } from 'react' +import { useGameAction } from '../../input' +import { BossArenaScreen } from './screens/BossArenaScreen' +import { + Iwt2CloudSaveScreen, + Iwt2CustomizeCharacterScreen, + Iwt2DungeonsScreen, + Iwt2HunterProfileScreen, + Iwt2ModePlaceholderScreen, + Iwt2SettingsScreen, +} from './screens/Iwt2ShellScreens' +import { + loadIwt2Save, + writeIwt2Save, + type Iwt2Save, +} from './save/iwt2Repository' +import type { Iwt2BossId } from './content/bosses' + +type Iwt2Screen = + | 'menu' + | 'arena' + | 'cloud-save' + | 'dungeons' + | 'raids' + | 'roguelike' + | 'pvp' + | 'hunter-profile' + | 'customize-character' + | 'settings' + +const IWT2_MENU_COLUMNS = 4 + +const MENU_ITEMS: Array<{ + screen: Iwt2Screen + title: string + description: string + glyph: string +}> = [ + { + screen: 'cloud-save', + title: 'Cloud Save', + description: 'Account sync shell for IWT2 progression and local save status.', + glyph: 'C', + }, + { + screen: 'dungeons', + title: 'Dungeons', + description: 'Queue into IWT2 boss arenas. Bulldrome and Yian Kut Ku are playable slices.', + glyph: 'D', + }, + { + screen: 'raids', + title: 'Raids', + description: 'Large-party encounter shell for future multi-group boss fights.', + glyph: 'R', + }, + { + screen: 'roguelike', + title: 'Roguelike', + description: 'Run-based IWT2 progression shell for room chains and reward drafts.', + glyph: 'G', + }, + { + screen: 'pvp', + title: 'PvP', + description: 'Competitive healing shell with controller targeting preserved.', + glyph: 'P', + }, + { + screen: 'hunter-profile', + title: 'Hunter Profile', + description: 'IWT2 level, inventory summary, and collection log.', + glyph: 'H', + }, + { + screen: 'customize-character', + title: 'Customize Character', + description: 'IWT2-only character identity and cosmetic setup shell.', + glyph: 'K', + }, + { + screen: 'settings', + title: 'Settings', + description: 'IWT2 targeting preference and controller icon style.', + glyph: 'S', + }, +] + +export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) { + const [screen, setScreen] = useState('menu') + const [save, setSave] = useState(loadIwt2Save) + const [selectedIndex, setSelectedIndex] = useState(0) + const [selectedBossId, setSelectedBossId] = useState('bulldrome') + + useEffect(() => { + writeIwt2Save(save) + }, [save]) + + useGameAction((action, device) => { + if (screen !== 'menu' || device !== 'controller') return + if (action === 'back') { + onBackToGameSelect() + return + } + if (action === 'confirm') { + setScreen(MENU_ITEMS[selectedIndex].screen) + return + } + if (action === 'navigateUp' || action === 'navigateLeft') { + const offset = action === 'navigateUp' ? IWT2_MENU_COLUMNS : 1 + setSelectedIndex((current) => Math.max(0, current - offset)) + } else if (action === 'navigateDown' || action === 'navigateRight') { + const offset = action === 'navigateDown' ? IWT2_MENU_COLUMNS : 1 + setSelectedIndex((current) => Math.min(MENU_ITEMS.length - 1, current + offset)) + } + }) + + if (screen === 'arena') { + return ( + setScreen('menu')} + onSaveUpdated={setSave} + /> + ) + } + + if (screen === 'dungeons') { + return ( +
+ + setScreen('menu')} + onOpenBoss={(bossId) => { + setSelectedBossId(bossId) + setScreen('arena') + }} + /> +
+ ) + } + + if (screen === 'raids' || screen === 'roguelike' || screen === 'pvp') { + const modeName = screen === 'raids' ? 'Raids' : screen === 'roguelike' ? 'Roguelike' : 'PvP' + return ( +
+ + setScreen('menu')} /> +
+ ) + } + + if (screen === 'hunter-profile') { + return ( +
+ + setScreen('menu')} /> +
+ ) + } + + if (screen === 'customize-character') { + return ( +
+ + setScreen('menu')} /> +
+ ) + } + + if (screen === 'cloud-save') { + return ( +
+ + setScreen('menu')} /> +
+ ) + } + + if (screen === 'settings') { + return ( +
+ + setScreen('menu')} /> +
+ ) + } + + return ( +
+ + + {screen === 'menu' && ( +
+
+

I Want To Heal 2

+

Mode Select

+
+
+ {MENU_ITEMS.map((item, index) => ( + + ))} +
+
+ )} +
+ ) +} + +function Iwt2Header({ + onBackToGameSelect, + save, +}: { + onBackToGameSelect: () => void + save: Iwt2Save +}) { + return ( +
+ +
+ {save.character.name} + IWT2 Level {save.character.level} + {save.character.experience} XP +
+
+ ) +} diff --git a/src/modes/iwt2/Iwt2BottomDisplay.tsx b/src/modes/iwt2/Iwt2BottomDisplay.tsx new file mode 100644 index 0000000..ae33711 --- /dev/null +++ b/src/modes/iwt2/Iwt2BottomDisplay.tsx @@ -0,0 +1,78 @@ +import { ControllerBindingLabel } from '../../components/ControllerIcons' +import { DEFAULT_BINDINGS, useInput } from '../../input' +import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER } from './content/classes' +import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from './content/controls' +import { abilitiesForHealer } from './content/healerAbilities' + +export function Iwt2BottomDisplay() { + const { + bindings, + directPartyTargeting, + lastDevice, + } = useInput() + const activeBindings = lastDevice === 'controller' + ? bindings.controller + : DEFAULT_BINDINGS.controller + const abilities = abilitiesForHealer('field_medic') + const partyTargets = IWT2_PARTY_ORDER.map((classId) => IWT2_CLASS_METADATA[classId]) + + return ( +
+
+
+

I Want To Heal 2

+ Field Medic +
+
+ Mana 100 / 100 +
+
+
+
+ {directPartyTargeting ? ( + partyTargets.map((target, index) => { + const action = IWT2_TARGET_ACTIONS[index] ?? 'targetParty1' + const label = target.id === 'healer' ? 'Player' : target.name.replace(' Tank', '') + return ( + + ) + }) + ) : ( + <> + + + + )} +
+
+ {abilities.map((ability, index) => { + const action = IWT2_ABILITY_ACTIONS[index] ?? 'ability1' + return ( + + ) + })} +
+
+ ) +} diff --git a/src/modes/iwt2/components/AbilityBar.tsx b/src/modes/iwt2/components/AbilityBar.tsx new file mode 100644 index 0000000..5b50002 --- /dev/null +++ b/src/modes/iwt2/components/AbilityBar.tsx @@ -0,0 +1,37 @@ +import type { Iwt2HealerAbility } from '../content/healerAbilities' + +export function AbilityBar({ + abilities, + cooldowns, + mana, + onCast, +}: { + abilities: Iwt2HealerAbility[] + cooldowns: Record + mana: number + onCast: (ability: Iwt2HealerAbility) => void +}) { + return ( +
+ {abilities.map((ability) => { + const remaining = cooldowns[ability.id] ?? 0 + const disabled = remaining > 0 || mana < ability.manaCost + return ( + + ) + })} +
+ ) +} diff --git a/src/modes/iwt2/components/ArenaBars.tsx b/src/modes/iwt2/components/ArenaBars.tsx new file mode 100644 index 0000000..cff62bc --- /dev/null +++ b/src/modes/iwt2/components/ArenaBars.tsx @@ -0,0 +1,20 @@ +export function ArenaBar({ + className = '', + current, + max, + shield = 0, +}: { + className?: string + current: number + max: number + shield?: number +}) { + const percent = max > 0 ? Math.max(0, Math.min(100, (current / max) * 100)) : 0 + const shieldPercent = max > 0 ? Math.max(0, Math.min(100, (shield / max) * 100)) : 0 + return ( +
+ + {shieldPercent > 0 && } +
+ ) +} diff --git a/src/modes/iwt2/components/BossHud.tsx b/src/modes/iwt2/components/BossHud.tsx new file mode 100644 index 0000000..ee2b3df --- /dev/null +++ b/src/modes/iwt2/components/BossHud.tsx @@ -0,0 +1,17 @@ +import type { Iwt2BossEntityState } from '../sim' +import { ArenaBar } from './ArenaBars' +import { IWT2_BOSS_METADATA } from '../content/bosses' + +export function BossHud({ boss }: { boss: Iwt2BossEntityState }) { + const metadata = IWT2_BOSS_METADATA[boss.bossId] + return ( +
+
+ {metadata.name} + {Math.ceil(boss.health)} / {boss.maxHealth} HP +
+ {boss.attackPhase} + +
+ ) +} diff --git a/src/modes/iwt2/components/PartyFrames.tsx b/src/modes/iwt2/components/PartyFrames.tsx new file mode 100644 index 0000000..855e768 --- /dev/null +++ b/src/modes/iwt2/components/PartyFrames.tsx @@ -0,0 +1,71 @@ +import { ControllerBindingLabel } from '../../../components/ControllerIcons' +import type { ControllerIconStyle } from '../../../input' +import { IWT2_CLASS_METADATA } from '../content/classes' +import type { Iwt2EntityId, Iwt2PartyEntityState } from '../sim' +import { ArenaBar } from './ArenaBars' + +export function PartyFrames({ + party, + selectedPartyId, + targetBindings, + controllerIconStyle, + onTarget, +}: { + party: Iwt2PartyEntityState[] + selectedPartyId: Iwt2EntityId + targetBindings?: Array + controllerIconStyle?: ControllerIconStyle + onTarget: (id: Iwt2EntityId) => void +}) { + return ( + + ) +} diff --git a/src/modes/iwt2/content/balanceOverrides.ts b/src/modes/iwt2/content/balanceOverrides.ts new file mode 100644 index 0000000..6b6e613 --- /dev/null +++ b/src/modes/iwt2/content/balanceOverrides.ts @@ -0,0 +1,6 @@ +import type { Iwt2BalanceOverrides } from './bosses' + +// Generated by local admin panel. Commit this file with intended IWT2 balance changes. +export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = { + bosses: {}, +} diff --git a/src/modes/iwt2/content/bosses.ts b/src/modes/iwt2/content/bosses.ts new file mode 100644 index 0000000..1727f44 --- /dev/null +++ b/src/modes/iwt2/content/bosses.ts @@ -0,0 +1,134 @@ +import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides' + +export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' + +export type Iwt2BalanceOverrides = { + bosses?: Partial>>> +} + +export type Iwt2BossMetadata = { + id: Iwt2BossId + name: string + icon: string + color: string + accentColor: string + maxHealth: number + radius: number + moveSpeed: number + meleeRange: number + meleeDamage: number + meleeCooldown: number + chargeCooldown: number + chargeWindup: number + chargeSpeed: number + chargeDamage: number + chargeStunSeconds: number + chargeLength: number + chargeOvershoot: number + chargeWidth: number + slamWindup: number + slamRadius: number + slamDamage: number + slamStunSeconds: number + fireballCooldown?: number + fireballWindup?: number + fireballSpeed?: number + fireballDamage?: number + fireballRadius?: number + firePuddleRadius?: number + firePuddleDamage?: number + firePuddleSeconds?: number + birdWaveThresholds?: number[] + birdFlightCooldown?: number + birdFlightWindup?: number + birdFlightSpeed?: number + birdHealth?: number + birdRadius?: number + birdContactDamage?: number + birdStunSeconds?: number +} + +const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = { + id: 'bulldrome', + name: 'Bulldrome', + icon: 'B', + color: '#8f5a3c', + accentColor: '#e6b17f', + maxHealth: 950, + radius: 31, + moveSpeed: 128, + meleeRange: 58, + meleeDamage: 9, + meleeCooldown: 1.05, + chargeCooldown: 4.75, + chargeWindup: 0.55, + chargeSpeed: 510, + chargeDamage: 32, + chargeStunSeconds: 0.75, + chargeLength: 560, + chargeOvershoot: 120, + chargeWidth: 62, + slamWindup: 0.45, + slamRadius: 104, + slamDamage: 24, + slamStunSeconds: 0.75, +} + +const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = { + id: 'yian-kut-ku', + name: 'Yian Kut Ku', + icon: 'Y', + color: '#d66a35', + accentColor: '#ffd166', + maxHealth: 400, + radius: 29, + moveSpeed: 118, + meleeRange: 56, + meleeDamage: 8, + meleeCooldown: 1, + chargeCooldown: 0, + chargeWindup: 0, + chargeSpeed: 0, + chargeDamage: 0, + chargeStunSeconds: 0, + chargeLength: 0, + chargeOvershoot: 0, + chargeWidth: 0, + slamWindup: 0, + slamRadius: 0, + slamDamage: 0, + slamStunSeconds: 0, + fireballCooldown: 3.6, + fireballWindup: 0.55, + fireballSpeed: 285, + fireballDamage: 12, + fireballRadius: 9, + firePuddleRadius: 42, + firePuddleDamage: 7, + firePuddleSeconds: 8, + birdWaveThresholds: [0.9, 0.4], + birdFlightCooldown: 10, + birdFlightWindup: 0.85, + birdFlightSpeed: 360, + birdHealth: 58, + birdRadius: 16, + birdContactDamage: 10, + birdStunSeconds: 0.75, +} + +function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata { + const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id] + if (!override) return metadata + return { + ...metadata, + ...override, + } +} + +export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BULLDROME_BOSS_METADATA) +export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA) + +export const IWT2_BOSS_METADATA: Record = { + bulldrome: BULLDROME_BOSS_METADATA, + 'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA, +} diff --git a/src/modes/iwt2/content/classes.ts b/src/modes/iwt2/content/classes.ts new file mode 100644 index 0000000..fac7d2c --- /dev/null +++ b/src/modes/iwt2/content/classes.ts @@ -0,0 +1,121 @@ +export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogue' | 'warrior' + +export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage' + +export type Iwt2ClassMetadata = { + id: Iwt2PlayerClassId + name: string + role: Iwt2PlayerClassRole + icon: string + color: string + accentColor: string + maxHealth: number + moveSpeed: number + radius: number + attackRange: number + attackDamage: number + attackCooldown: number + castTime: number + projectileSpeed: number +} + +export const IWT2_CLASS_METADATA: Record = { + healer: { + id: 'healer', + name: 'Player Healer', + role: 'healer', + icon: '+', + color: '#2fbf71', + accentColor: '#b7f7cf', + maxHealth: 125, + moveSpeed: 210, + radius: 15, + attackRange: 0, + attackDamage: 0, + attackCooldown: 1, + castTime: 0, + projectileSpeed: 0, + }, + paladin: { + id: 'paladin', + name: 'Paladin Tank', + role: 'tank', + icon: '🛡', + color: '#f2c94c', + accentColor: '#fff0a8', + maxHealth: 190, + moveSpeed: 165, + radius: 17, + attackRange: 44, + attackDamage: 7, + attackCooldown: 0.85, + castTime: 0, + projectileSpeed: 0, + }, + ranger: { + id: 'ranger', + name: 'Ranger', + role: 'damage', + icon: '🏹', + color: '#4aa3df', + accentColor: '#b9e3ff', + maxHealth: 115, + moveSpeed: 185, + radius: 14, + attackRange: 245, + attackDamage: 8, + attackCooldown: 0.62, + castTime: 0.32, + projectileSpeed: 410, + }, + mage: { + id: 'mage', + name: 'Mage', + role: 'damage', + icon: '⚚', + color: '#c56cf0', + accentColor: '#efc4ff', + maxHealth: 100, + moveSpeed: 175, + radius: 14, + attackRange: 220, + attackDamage: 11, + attackCooldown: 0.78, + castTime: 0.42, + projectileSpeed: 330, + }, + rogue: { + id: 'rogue', + name: 'Rogue', + role: 'damage', + icon: '††', + color: '#55efc4', + accentColor: '#c8fff2', + maxHealth: 110, + moveSpeed: 230, + radius: 14, + attackRange: 38, + attackDamage: 6, + attackCooldown: 0.38, + castTime: 0, + projectileSpeed: 0, + }, + warrior: { + id: 'warrior', + name: 'Warrior', + role: 'damage', + icon: '⚔', + color: '#ff7675', + accentColor: '#ffd0d0', + maxHealth: 150, + moveSpeed: 180, + radius: 16, + attackRange: 42, + attackDamage: 9, + attackCooldown: 0.52, + castTime: 0, + projectileSpeed: 0, + }, +} + +export const IWT2_PARTY_ORDER: Iwt2PlayerClassId[] = ['healer', 'paladin', 'ranger', 'mage', 'rogue', 'warrior'] diff --git a/src/modes/iwt2/content/controls.ts b/src/modes/iwt2/content/controls.ts new file mode 100644 index 0000000..386c34c --- /dev/null +++ b/src/modes/iwt2/content/controls.ts @@ -0,0 +1,19 @@ +import type { InputAction } from '../../../input' + +export const IWT2_ABILITY_ACTIONS: InputAction[] = [ + 'ability1', + 'ability2', + 'ability3', + 'ability4', + 'ability5', + 'ability6', +] + +export const IWT2_TARGET_ACTIONS: InputAction[] = [ + 'targetParty1', + 'targetParty2', + 'targetParty3', + 'targetParty4', + 'targetParty5', + 'targetParty6', +] diff --git a/src/modes/iwt2/content/healerAbilities.ts b/src/modes/iwt2/content/healerAbilities.ts new file mode 100644 index 0000000..35e1655 --- /dev/null +++ b/src/modes/iwt2/content/healerAbilities.ts @@ -0,0 +1,125 @@ +import type { Spell } from '../../../game' +import type { Ability } from '../../../profile' +import { toCombatSpell } from '../../../combat/rules' + +export type Iwt2AbilityTarget = 'party-member' | 'self' | 'ground' + +export type Iwt2HealerAbility = { + id: string + healerId: string + slot: number + name: string + icon: string + kind: Spell['kind'] + power: number + effectType?: string + manaCost: number + cooldownSeconds: number + target: Iwt2AbilityTarget +} + +const IWT1_DAWNWEAVER_ABILITIES: Ability[] = [ + { + id: 1, + classId: 1, + slug: 'mend', + name: 'Mend', + spellType: 'direct_heal', + cost: 5, + cooldown: 0.5, + power: 30, + unlockLevel: 1, + glyph: '+', + description: 'A fast, efficient single-target heal.', + }, + { + id: 2, + classId: 1, + slug: 'renew', + name: 'Renew', + spellType: 'heal_over_time', + cost: 7, + cooldown: 0.5, + power: 12, + unlockLevel: 1, + glyph: '~', + description: 'Heals now and continues healing over time.', + }, + { + id: 3, + classId: 1, + slug: 'radiance', + name: 'Radiance', + spellType: 'party_heal', + cost: 12, + cooldown: 8, + power: 18, + unlockLevel: 1, + glyph: '*', + description: 'Restores health to up to 4 injured party members.', + }, + { + id: 4, + classId: 1, + slug: 'sun-ward', + name: 'Sun Ward', + spellType: 'absorb', + cost: 8, + cooldown: 7, + power: 36, + unlockLevel: 1, + glyph: 'O', + description: 'Places a damage-absorbing shield on your target.', + }, + { + id: 5, + classId: 1, + slug: 'purify', + name: 'Purify', + spellType: 'cleanse', + cost: 5, + cooldown: 5, + power: 10, + unlockLevel: 1, + glyph: 'x', + description: 'Removes a harmful effect and restores health.', + }, + { + id: 6, + classId: 1, + slug: 'dawn-burst', + name: 'Dawn Burst', + spellType: 'party_heal', + cost: 16, + cooldown: 12, + power: 28, + unlockLevel: 5, + glyph: 'D', + description: 'A brilliant wave of healing for up to 4 injured allies.', + }, +] + +function toIwt2Ability(ability: Ability, index: number): Iwt2HealerAbility { + const spell = toCombatSpell(ability, String(index + 1)) + return { + id: spell.id, + healerId: 'field_medic', + slot: index + 1, + name: spell.name, + icon: spell.glyph, + kind: spell.kind, + power: spell.power, + effectType: spell.effectType, + manaCost: spell.cost, + cooldownSeconds: spell.cooldown, + target: 'party-member', + } +} + +export const IWT2_HEALER_ABILITIES: Record = { + field_medic: IWT1_DAWNWEAVER_ABILITIES.map(toIwt2Ability), +} + +export function abilitiesForHealer(healerId: string) { + return IWT2_HEALER_ABILITIES[healerId] ?? IWT2_HEALER_ABILITIES.field_medic +} diff --git a/src/modes/iwt2/content/index.ts b/src/modes/iwt2/content/index.ts new file mode 100644 index 0000000..1e6d869 --- /dev/null +++ b/src/modes/iwt2/content/index.ts @@ -0,0 +1,18 @@ +export { + IWT2_CLASS_METADATA, + IWT2_PARTY_ORDER, +} from './classes' +export type { + Iwt2ClassMetadata, + Iwt2PlayerClassId, + Iwt2PlayerClassRole, +} from './classes' + +export { + BULLDROME_BOSS_METADATA, + IWT2_BOSS_METADATA, +} from './bosses' +export type { + Iwt2BossId, + Iwt2BossMetadata, +} from './bosses' diff --git a/src/modes/iwt2/render/PhaserArena.tsx b/src/modes/iwt2/render/PhaserArena.tsx new file mode 100644 index 0000000..8e67898 --- /dev/null +++ b/src/modes/iwt2/render/PhaserArena.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef, type MutableRefObject } from 'react' +import Phaser from 'phaser' +import type { MovementVector } from '../../../input' +import { BulldromeArenaScene } from './scenes/BulldromeArenaScene' +import type { Iwt2ArenaState } from '../sim/arenaState' + +type PhaserArenaProps = { + movementRef: MutableRefObject + selectedPartyIdRef: MutableRefObject + stateRef: MutableRefObject + onStep: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState +} + +export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) { + const hostRef = useRef(null) + + useEffect(() => { + if (!hostRef.current) return undefined + const initialState = stateRef.current + const scene = new BulldromeArenaScene({ + getMovement: () => movementRef.current, + getSelectedPartyId: () => selectedPartyIdRef.current, + getState: () => stateRef.current, + step: onStep, + }) + const game = new Phaser.Game({ + type: Phaser.AUTO, + parent: hostRef.current, + width: initialState.bounds.width, + height: initialState.bounds.height, + backgroundColor: '#10141b', + pixelArt: false, + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + scene, + }) + + return () => { + game.destroy(true) + } + }, [movementRef, onStep, selectedPartyIdRef, stateRef]) + + return
+} diff --git a/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts new file mode 100644 index 0000000..6772063 --- /dev/null +++ b/src/modes/iwt2/render/scenes/BulldromeArenaScene.ts @@ -0,0 +1,281 @@ +import Phaser from 'phaser' +import type { MovementVector } from '../../../../input' +import { IWT2_BOSS_METADATA } from '../../content/bosses' +import { IWT2_CLASS_METADATA } from '../../content/classes' +import type { + Iwt2ArenaIndicator, + Iwt2ArenaState, + Iwt2BossEntityState, + Iwt2GroundHazardState, + Iwt2HostileAddState, + Iwt2PartyEntityState, + Iwt2Vec2, +} from '../../sim' + +type SceneDeps = { + getMovement: () => MovementVector + getSelectedPartyId: () => string + getState: () => Iwt2ArenaState + step: (movement: MovementVector, dtSeconds: number) => Iwt2ArenaState +} + +type DrawableEntity = Iwt2PartyEntityState | Iwt2BossEntityState | Iwt2HostileAddState + +export class BulldromeArenaScene extends Phaser.Scene { + private deps: SceneDeps + private arenaGraphics?: Phaser.GameObjects.Graphics + private entityGraphics?: Phaser.GameObjects.Graphics + private telegraphGraphics?: Phaser.GameObjects.Graphics + private labels = new Map() + private lastHudPublish = 0 + + constructor(deps: SceneDeps) { + super('bulldrome-arena') + this.deps = deps + } + + create() { + this.cameras.main.setBackgroundColor('#10141b') + this.arenaGraphics = this.add.graphics() + this.telegraphGraphics = this.add.graphics() + this.entityGraphics = this.add.graphics() + this.drawState(this.deps.getState()) + } + + update(_: number, delta: number) { + const state = this.deps.step(this.deps.getMovement(), Math.min(0.05, delta / 1000)) + this.drawState(state) + } + + private drawState(state: Iwt2ArenaState) { + if (!this.arenaGraphics || !this.entityGraphics || !this.telegraphGraphics) return + this.drawArena(state) + this.drawTelegraphs(state) + this.drawEntities(state) + this.drawProjectiles(state) + this.lastHudPublish += 1 + } + + private drawArena(state: Iwt2ArenaState) { + const graphics = this.arenaGraphics! + graphics.clear() + graphics.lineStyle(3, 0x3c4a59, 1) + graphics.fillStyle(0x171d24, 1) + graphics.fillRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12) + graphics.strokeRoundedRect(0, 0, state.bounds.width, state.bounds.height, 12) + + graphics.lineStyle(1, 0x26313d, 0.55) + for (let x = 80; x < state.bounds.width; x += 80) { + graphics.lineBetween(x, 0, x, state.bounds.height) + } + for (let y = 80; y < state.bounds.height; y += 80) { + graphics.lineBetween(0, y, state.bounds.width, y) + } + } + + private drawTelegraphs(state: Iwt2ArenaState) { + const graphics = this.telegraphGraphics! + graphics.clear() + state.indicators.forEach((indicator) => drawIndicator(graphics, indicator)) + } + + private drawEntities(state: Iwt2ArenaState) { + const graphics = this.entityGraphics! + graphics.clear() + for (const hazard of state.hazards) drawHazard(graphics, hazard) + const entities: DrawableEntity[] = [...state.party, ...state.hostileAdds, state.boss] + const liveIds = new Set(entities.map((entity) => entity.id)) + + for (const entity of entities.sort(entitySort)) { + const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) + const alpha = entity.health <= 0 ? 0.35 : stunned ? 0.55 : 1 + const color = entityColor(entity) + graphics.fillStyle(Number.parseInt(color.slice(1), 16), alpha) + graphics.lineStyle(entity.kind === 'party' && entity.aiRole === 'player' ? 4 : 2, entity.kind === 'boss' ? 0xfff0b8 : 0x0a0c10, 1) + if (entity.kind === 'boss') { + graphics.fillEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75) + graphics.strokeEllipse(entity.position.x, entity.position.y, entity.radius * 2.4, entity.radius * 1.75) + } else if (entity.kind === 'hostileAdd') { + const wing = entity.radius * 1.55 + graphics.fillTriangle( + entity.position.x + entity.facing.x * wing, + entity.position.y, + entity.position.x - entity.facing.x * entity.radius, + entity.position.y - entity.radius, + entity.position.x - entity.facing.x * entity.radius, + entity.position.y + entity.radius, + ) + graphics.strokeTriangle( + entity.position.x + entity.facing.x * wing, + entity.position.y, + entity.position.x - entity.facing.x * entity.radius, + entity.position.y - entity.radius, + entity.position.x - entity.facing.x * entity.radius, + entity.position.y + entity.radius, + ) + } else { + if (entity.id === this.deps.getSelectedPartyId()) { + graphics.lineStyle(4, 0xfff4a8, 0.95) + graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius + 8) + } + graphics.fillCircle(entity.position.x, entity.position.y, entity.radius) + graphics.strokeCircle(entity.position.x, entity.position.y, entity.radius) + } + this.drawLabel(entity) + this.drawHealthBar(graphics, entity) + } + + for (const [id, label] of this.labels) { + if (!liveIds.has(id)) { + label.destroy() + this.labels.delete(id) + } + } + } + + private drawProjectiles(state: Iwt2ArenaState) { + const graphics = this.entityGraphics! + for (const projectile of state.projectiles) { + const color = Number.parseInt(projectile.color.slice(1), 16) + graphics.fillStyle(color, 0.95) + graphics.lineStyle(projectile.projectileKind === 'magic' || projectile.projectileKind === 'fireball' ? 3 : 2, color, 0.75) + graphics.strokeCircle(projectile.position.x, projectile.position.y, projectile.radius + 3) + graphics.fillCircle(projectile.position.x, projectile.position.y, projectile.radius) + } + } + + private drawLabel(entity: DrawableEntity) { + let label = this.labels.get(entity.id) + const icon = entityIcon(entity) + if (!label) { + label = this.add.text(entity.position.x, entity.position.y, icon, { + align: 'center', + color: '#fff7df', + fontFamily: 'ui-monospace, Consolas, monospace', + fontSize: entity.kind === 'boss' ? '20px' : entity.kind === 'hostileAdd' ? '13px' : '15px', + fontStyle: '900', + }).setOrigin(0.5) + this.labels.set(entity.id, label) + } + const stunned = entity.kind === 'party' && (entity.status.stunnedSeconds > 0 || entity.status.knockedDownSeconds > 0) + label.setText(stunned ? '!' : icon) + label.setPosition(entity.position.x, entity.position.y - (entity.kind === 'boss' ? 1 : 0)) + label.setAlpha(entity.health <= 0 ? 0.35 : 1) + } + + private drawHealthBar(graphics: Phaser.GameObjects.Graphics, entity: DrawableEntity) { + const width = entity.kind === 'boss' ? 92 : 34 + const height = entity.kind === 'boss' ? 8 : 7 + const y = entity.position.y + entity.radius + 8 + const ratio = Math.max(0, Math.min(1, entity.health / entity.maxHealth)) + graphics.fillStyle(0x050608, 0.9) + graphics.fillRect(entity.position.x - width / 2, y, width, height) + graphics.fillStyle(ratio > 0.5 ? 0x4ac77d : ratio > 0.25 ? 0xf1c663 : 0xf05b4f, 1) + graphics.fillRect(entity.position.x - width / 2, y, width * ratio, height) + if (entity.kind === 'party' && entity.shield > 0) { + const shieldRatio = Math.max(0, Math.min(1, entity.shield / entity.maxHealth)) + graphics.fillStyle(0x58a8ff, 0.92) + graphics.fillRect(entity.position.x + width / 2 - width * shieldRatio, y, width * shieldRatio, height) + } + } +} + +function entitySort(a: DrawableEntity, b: DrawableEntity): number { + const rank = { boss: 0, hostileAdd: 1, party: 2 } satisfies Record + return rank[a.kind] - rank[b.kind] +} + +function entityColor(entity: DrawableEntity): string { + if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].color + if (entity.kind === 'hostileAdd') return '#f0b84f' + return IWT2_CLASS_METADATA[entity.classId].color +} + +function entityIcon(entity: DrawableEntity): string { + if (entity.kind === 'boss') return IWT2_BOSS_METADATA[entity.bossId].icon + if (entity.kind === 'hostileAdd') return 'v' + return IWT2_CLASS_METADATA[entity.classId].icon +} + +function drawHazard(graphics: Phaser.GameObjects.Graphics, hazard: Iwt2GroundHazardState) { + const ratio = Math.max(0, Math.min(1, hazard.remainingSeconds / Math.max(0.001, hazard.fadeSeconds))) + const alpha = hazard.remainingSeconds <= hazard.fadeSeconds ? 0.16 + ratio * 0.2 : 0.34 + graphics.fillStyle(0xff7a2f, alpha) + graphics.lineStyle(2, 0xffc15a, 0.55) + graphics.fillCircle(hazard.position.x, hazard.position.y, hazard.radius) + graphics.strokeCircle(hazard.position.x, hazard.position.y, hazard.radius) +} + +function drawIndicator(graphics: Phaser.GameObjects.Graphics, indicator: Iwt2ArenaIndicator) { + const color = Number.parseInt(indicator.color.slice(1), 16) + const active = indicator.phase === 'active' + graphics.lineStyle(active ? 8 : 5, color, indicator.lineAlpha ?? (active ? 0.76 : 0.5)) + graphics.fillStyle(color, indicator.fillAlpha ?? (active ? 0.16 : 0.09)) + + if (indicator.kind === 'lane') { + const points = laneDangerPolygon(indicator.start, indicator.end, indicator.width) + graphics.fillPoints(points, true) + graphics.strokePoints(points, true) + return + } + + if (indicator.kind === 'circle') { + graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.radius) + graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.radius) + return + } + + if (indicator.kind === 'cone') { + const points = coneDangerPolygon( + indicator.origin, + indicator.direction, + indicator.range, + indicator.angleRadians, + ) + graphics.fillPoints(points, true) + graphics.strokePoints(points, true) + return + } + + graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.outerRadius) + graphics.fillCircle(indicator.position.x, indicator.position.y, indicator.outerRadius) + graphics.lineStyle(active ? 5 : 3, color, indicator.lineAlpha ?? 0.6) + graphics.strokeCircle(indicator.position.x, indicator.position.y, indicator.innerRadius) +} + +function laneDangerPolygon(start: Iwt2Vec2, end: Iwt2Vec2, halfWidth: number) { + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.max(1, Math.hypot(dx, dy)) + const normal = { + x: (-dy / length) * halfWidth, + y: (dx / length) * halfWidth, + } + return [ + new Phaser.Math.Vector2(start.x + normal.x, start.y + normal.y), + new Phaser.Math.Vector2(end.x + normal.x, end.y + normal.y), + new Phaser.Math.Vector2(end.x - normal.x, end.y - normal.y), + new Phaser.Math.Vector2(start.x - normal.x, start.y - normal.y), + ] +} + +function coneDangerPolygon( + origin: Iwt2Vec2, + direction: Iwt2Vec2, + range: number, + angleRadians: number, +) { + const baseAngle = Math.atan2(direction.y, direction.x) + const halfAngle = angleRadians / 2 + const points = [new Phaser.Math.Vector2(origin.x, origin.y)] + const segmentCount = 14 + for (let index = 0; index <= segmentCount; index += 1) { + const t = index / segmentCount + const angle = baseAngle - halfAngle + angleRadians * t + points.push(new Phaser.Math.Vector2( + origin.x + Math.cos(angle) * range, + origin.y + Math.sin(angle) * range, + )) + } + return points +} diff --git a/src/modes/iwt2/save/iwt2Repository.ts b/src/modes/iwt2/save/iwt2Repository.ts new file mode 100644 index 0000000..dec1d8e --- /dev/null +++ b/src/modes/iwt2/save/iwt2Repository.ts @@ -0,0 +1,115 @@ +export type Iwt2InventoryItem = { + id: string + name: string + quantity: number + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' +} + +export type Iwt2CollectionLog = { + bossKills: Record + dropsFound: Record +} + +export type Iwt2Save = { + version: 1 + updatedAt: number + character: { + name: string + level: number + experience: number + } + inventory: Iwt2InventoryItem[] + collectionLog: Iwt2CollectionLog +} + +const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1' + +export function createDefaultIwt2Save(): Iwt2Save { + return { + version: 1, + updatedAt: Date.now(), + character: { + name: 'Healer', + level: 1, + experience: 0, + }, + inventory: [], + collectionLog: { + bossKills: {}, + dropsFound: {}, + }, + } +} + +function normalizeSave(value: unknown): Iwt2Save { + if (!value || typeof value !== 'object') return createDefaultIwt2Save() + const candidate = value as Partial + if (candidate.version !== 1) return createDefaultIwt2Save() + return { + version: 1, + updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(), + character: { + name: candidate.character?.name || 'Healer', + level: Math.max(1, Math.floor(candidate.character?.level ?? 1)), + experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)), + }, + inventory: Array.isArray(candidate.inventory) ? candidate.inventory : [], + collectionLog: { + bossKills: candidate.collectionLog?.bossKills ?? {}, + dropsFound: candidate.collectionLog?.dropsFound ?? {}, + }, + } +} + +export function loadIwt2Save(): Iwt2Save { + try { + return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null')) + } catch { + return createDefaultIwt2Save() + } +} + +export function writeIwt2Save(save: Iwt2Save) { + window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({ + ...save, + updatedAt: Date.now(), + })) +} + +export function recordIwt2BossKill(save: Iwt2Save, bossId: string): Iwt2Save { + const drop = bossDropFor(bossId) + return { + ...save, + updatedAt: Date.now(), + character: { + ...save.character, + experience: save.character.experience + 125, + level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + 125) / 500)), + }, + inventory: [ + ...save.inventory, + { + id: `${drop.id}-${Date.now()}`, + name: drop.name, + quantity: 1, + rarity: 'common', + }, + ], + collectionLog: { + ...save.collectionLog, + bossKills: { + ...save.collectionLog.bossKills, + [bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1, + }, + dropsFound: { + ...save.collectionLog.dropsFound, + [drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + 1, + }, + }, + } +} + +function bossDropFor(bossId: string): { id: string, name: string } { + if (bossId === 'yian-kut-ku') return { id: 'yian-kut-ku-scale', name: 'Yian Kut Ku Scale' } + return { id: 'raw-bulldrome-coin', name: 'Raw Bulldrome Coin' } +} diff --git a/src/modes/iwt2/screens/BossArenaScreen.tsx b/src/modes/iwt2/screens/BossArenaScreen.tsx new file mode 100644 index 0000000..b409d6d --- /dev/null +++ b/src/modes/iwt2/screens/BossArenaScreen.tsx @@ -0,0 +1,354 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import type { MovementVector } from '../../../input' +import { useGameAction, useInput, useMovementVectorRef } from '../../../input' +import { + createInitialIwt2ArenaState, + tickIwt2Arena, + type Iwt2ArenaState, +} from '../sim/arenaState' +import type { Iwt2EntityId } from '../sim' +import { castIwt2HealerAbility } from '../sim' +import { PhaserArena } from '../render/PhaserArena' +import { + recordIwt2BossKill, + type Iwt2Save, +} from '../save/iwt2Repository' +import { AbilityBar } from '../components/AbilityBar' +import { BossHud } from '../components/BossHud' +import { PartyFrames } from '../components/PartyFrames' +import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls' +import { abilitiesForHealer, type Iwt2HealerAbility } from '../content/healerAbilities' +import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' + +type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat' +type OverlayAction = 'primary' | 'menu' + +type BossArenaScreenProps = { + bossId: Iwt2BossId + save: Iwt2Save + onBack: () => void + onSaveUpdated: (save: Iwt2Save) => void +} + +export function BossArenaScreen({ bossId, save, onBack, onSaveUpdated }: BossArenaScreenProps) { + const bossMetadata = IWT2_BOSS_METADATA[bossId] + const [arenaState, setArenaState] = useState(() => createInitialIwt2ArenaState(bossId)) + const [abilityCooldowns, setAbilityCooldowns] = useState>({}) + const [status, setStatus] = useState('playing') + const [selectedOverlayAction, setSelectedOverlayAction] = useState('primary') + const [selectedPartyId, setSelectedPartyId] = useState('player-healer') + const stateRef = useRef(arenaState) + const statusRef = useRef(status) + const selectedPartyIdRef = useRef(selectedPartyId) + const saveRef = useRef(save) + const killRecordedRef = useRef(false) + const lastPublishTimeRef = useRef(0) + const lastHudSignatureRef = useRef('') + const abilityCooldownsRef = useRef>({}) + const movementRef = useMovementVectorRef(status === 'playing') + const { + bindings, + controllerIconStyle, + directPartyTargeting, + lastDevice, + } = useInput() + const activeBindings = bindings[lastDevice] + + useEffect(() => { + stateRef.current = arenaState + }, [arenaState]) + + useEffect(() => { + statusRef.current = status + }, [status]) + + useEffect(() => { + selectedPartyIdRef.current = selectedPartyId + }, [selectedPartyId]) + + useEffect(() => { + saveRef.current = save + }, [save]) + + const resetArena = useCallback(() => { + const next = createInitialIwt2ArenaState(bossId) + killRecordedRef.current = false + abilityCooldownsRef.current = {} + lastHudSignatureRef.current = arenaHudSignature(next) + stateRef.current = next + setArenaState(next) + setAbilityCooldowns({}) + setSelectedOverlayAction('primary') + setStatus('playing') + }, [bossId]) + + const showOverlay = useCallback((nextStatus: Exclude) => { + setSelectedOverlayAction('primary') + setStatus(nextStatus) + }, []) + + const abilities = useMemo( + () => abilitiesForHealer('field_medic'), + [], + ) + + const castAbility = useCallback((ability: Iwt2HealerAbility) => { + if (statusRef.current !== 'playing') return + if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return + const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current) + if (!result.cast) return + + const nextCooldowns = { + ...abilityCooldownsRef.current, + [ability.id]: ability.cooldownSeconds, + } + abilityCooldownsRef.current = nextCooldowns + stateRef.current = result.state + lastHudSignatureRef.current = arenaHudSignature(result.state) + setAbilityCooldowns(nextCooldowns) + setArenaState(result.state) + }, []) + + useGameAction((action, device) => { + if (device === 'controller' && statusRef.current !== 'playing') { + if (action.startsWith('navigate')) { + setSelectedOverlayAction((current) => current === 'primary' ? 'menu' : 'primary') + return + } + if (action === 'confirm') { + if (selectedOverlayAction === 'menu') onBack() + else if (statusRef.current === 'paused') setStatus('playing') + else resetArena() + return + } + if (action === 'back') { + if (statusRef.current === 'paused') setStatus('playing') + else onBack() + return + } + } + if (statusRef.current === 'playing' && action.startsWith('ability')) { + const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)] + if (ability) castAbility(ability) + return + } + if ( + device === 'controller' + && statusRef.current === 'playing' + && !directPartyTargeting + && ( + action === 'navigateUp' + || action === 'navigateLeft' + || action === 'previousTarget' + || action === 'navigateDown' + || action === 'navigateRight' + || action === 'nextTarget' + ) + ) { + const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget' + setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1)) + return + } + if (action.startsWith('targetParty')) { + const index = Number(action.replace('targetParty', '')) - 1 + const target = stateRef.current.party[index] + if (target) setSelectedPartyId(target.id) + return + } + if (device !== 'controller') return + if (action === 'pause' || action === 'back') { + if (statusRef.current === 'playing') showOverlay('paused') + else if (statusRef.current === 'paused') setStatus('playing') + } + }) + + const onStep = useCallback((movement: MovementVector, dtSeconds: number) => { + if (statusRef.current !== 'playing') return stateRef.current + abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds) + const next = tickIwt2Arena(stateRef.current, { + moveX: movement.x, + moveY: movement.y, + }, dtSeconds) + stateRef.current = next + + if (next.boss.health <= 0 && !killRecordedRef.current) { + killRecordedRef.current = true + const updatedSave = recordIwt2BossKill(saveRef.current, bossId) + saveRef.current = updatedSave + onSaveUpdated(updatedSave) + showOverlay('victory') + } else if (next.party.every((member) => member.health <= 0)) { + showOverlay('defeat') + } + + const hudSignature = arenaHudSignature(next) + if ( + hudSignature !== lastHudSignatureRef.current + || next.time - lastPublishTimeRef.current >= 0.08 + || next.boss.health <= 0 + ) { + lastHudSignatureRef.current = hudSignature + lastPublishTimeRef.current = next.time + setArenaState(next) + setAbilityCooldowns(abilityCooldownsRef.current) + } + return next + }, [bossId, onSaveUpdated, showOverlay]) + + const targetBindings = directPartyTargeting + ? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null) + : undefined + const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0 + const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length + const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0) + const overlayPrimaryLabel = status === 'paused' ? 'Resume' : 'Restart' + const overlayTitle = status === 'victory' + ? `${bossMetadata.name} Down` + : status === 'defeat' + ? 'Party Defeated' + : 'Arena Paused' + const overlayEyebrow = status === 'victory' + ? 'Hunt Complete' + : status === 'defeat' + ? 'Recovery' + : 'Paused' + const overlayTone = status === 'victory' + ? 'is-victory' + : status === 'defeat' + ? 'is-defeat' + : 'is-paused' + + return ( +
+
+
+ + + + + {status !== 'playing' && ( +
+
+
+ {bossMetadata.icon} +
+

{overlayEyebrow}

+

{overlayTitle}

+ {status !== 'paused' && ( +
+ + {formatArenaTime(arenaState.time)} + Clear + + + {alivePartyCount}/{arenaState.party.length} + Standing + + + {Math.round(totalPartyDamage)} + Damage + +
+ )} + {status === 'victory' && ( +
+ +125 XP + Common carve + Log +1 +
+ )} +
+ + +
+ {lastDevice === 'controller' && ( + D-pad selects, A confirms, B exits + )} +
+
+ )} + + +
+
+
+ ) +} + +function formatArenaTime(seconds: number): string { + const safeSeconds = Math.max(0, Math.floor(seconds)) + const minutes = Math.floor(safeSeconds / 60) + const remainder = safeSeconds % 60 + return `${minutes}:${remainder.toString().padStart(2, '0')}` +} + +function arenaHudSignature(state: Iwt2ArenaState): string { + return [ + state.boss.id, + Math.ceil(state.boss.health), + state.boss.attackPhase, + state.hostileAdds.length, + state.hazards.length, + ...state.party.map((member) => [ + member.id, + Math.ceil(member.health), + Math.ceil(member.shield), + Math.ceil(member.mana), + Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10), + member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','), + ].join(':')), + ].join('|') +} + +function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId { + const living = state.party.filter((member) => member.health > 0) + const targets = living.length > 0 ? living : state.party + const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId)) + const nextIndex = (currentIndex + direction + targets.length) % targets.length + return targets[nextIndex]?.id ?? currentId +} + +function tickCooldowns(cooldowns: Record, dt: number) { + let changed = false + const next: Record = {} + for (const [id, remaining] of Object.entries(cooldowns)) { + const value = Math.max(0, remaining - dt) + if (value !== remaining) changed = true + if (value > 0) next[id] = value + } + return changed ? next : cooldowns +} diff --git a/src/modes/iwt2/screens/Iwt2ShellScreens.tsx b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx new file mode 100644 index 0000000..e0e178b --- /dev/null +++ b/src/modes/iwt2/screens/Iwt2ShellScreens.tsx @@ -0,0 +1,337 @@ +import { useMemo, useState, type ReactNode } from 'react' +import { ControllerStylePreview } from '../../../components/ControllerIcons' +import { useGameAction, useInput, type ControllerIconStyle } from '../../../input' +import type { Iwt2Save } from '../save/iwt2Repository' +import type { Iwt2BossId } from '../content/bosses' + +type Iwt2NavAction = { + key: string + label: string + detail?: string + value?: string + onConfirm: () => void +} + +type Iwt2ScreenShellProps = { + title: string + eyebrow?: string + onBack: () => void + children: ReactNode +} + +type Iwt2ActionListProps = { + actions: Iwt2NavAction[] +} + +function useIwt2ActionList(actions: Iwt2NavAction[]) { + const [selectedIndex, setSelectedIndex] = useState(0) + const activeIndex = Math.min(selectedIndex, Math.max(0, actions.length - 1)) + + useGameAction((action, device) => { + if (device !== 'controller' || actions.length === 0) return + if (action === 'navigateUp' || action === 'navigateLeft') { + setSelectedIndex((current) => Math.max(0, current - 1)) + } else if (action === 'navigateDown' || action === 'navigateRight') { + setSelectedIndex((current) => Math.min(actions.length - 1, current + 1)) + } else if (action === 'confirm') { + actions[activeIndex]?.onConfirm() + } + }) + + return { selectedIndex: activeIndex, setSelectedIndex } +} + +const MODE_DESCRIPTIONS = { + Raids: 'Large-party boss fights will use the same 2D arena contract with larger rosters and raid-scale mechanics.', + Roguelike: 'Runs will chain rooms, rewards, healer builds, and IWT2-only inventory progression.', + PvP: 'Player-vs-player healing scenarios will reuse controller targeting and class identity without IWT1 state bleed.', +} satisfies Record<'Raids' | 'Roguelike' | 'PvP', string> + +const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo'] + +const CONTROLLER_ICON_LABELS: Record = { + xbox: 'Xbox', + playstation: 'PlayStation', + nintendo: 'Nintendo', +} + +export function Iwt2ScreenShell({ + children, + eyebrow = 'I Want To Heal 2', + onBack, + title, +}: Iwt2ScreenShellProps) { + useGameAction((action, device) => { + if (device === 'controller' && action === 'back') onBack() + }) + + return ( +
+
+
+

{eyebrow}

+

{title}

+
+ +
+ {children} +
+ ) +} + +export function Iwt2ActionList({ actions }: Iwt2ActionListProps) { + const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions) + + return ( +
+ {actions.map((action, index) => ( + + ))} +
+ ) +} + +export function Iwt2DungeonsScreen({ + onBack, + onOpenBoss, +}: { + onBack: () => void + onOpenBoss: (bossId: Iwt2BossId) => void +}) { + const actions = useMemo(() => [ + { + key: 'bulldrome', + label: 'Bulldrome Arena', + detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.', + value: 'Ready', + onConfirm: () => onOpenBoss('bulldrome'), + }, + { + key: 'yian-kut-ku', + label: 'Yian Kut Ku Arena', + detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.', + value: 'Ready', + onConfirm: () => onOpenBoss('yian-kut-ku'), + }, + { + key: 'queue', + label: 'Dungeon Queue', + detail: 'Future matchmaking shell for multi-boss dungeon routes.', + value: 'Planned', + onConfirm: () => {}, + }, + ], [onOpenBoss]) + + return ( + + + + ) +} + +export function Iwt2ModePlaceholderScreen({ + mode, + onBack, +}: { + mode: 'Raids' | 'Roguelike' | 'PvP' + onBack: () => void +}) { + const actions = useMemo(() => [ + { + key: 'status', + label: `${mode} Prototype`, + detail: MODE_DESCRIPTIONS[mode], + value: 'Shell', + onConfirm: () => {}, + }, + ], [mode]) + + return ( + + + + ) +} + +export function Iwt2HunterProfileScreen({ + onBack, + save, +}: { + onBack: () => void + save: Iwt2Save +}) { + const bulldromeKills = save.collectionLog.bossKills.bulldrome ?? 0 + const yianKills = save.collectionLog.bossKills['yian-kut-ku'] ?? 0 + const bulldromeDrops = save.collectionLog.dropsFound['raw-bulldrome-coin'] ?? 0 + const yianDrops = save.collectionLog.dropsFound['yian-kut-ku-scale'] ?? 0 + + return ( + +
+
+
+ Name + {save.character.name} +
+
+ Level + {save.character.level} +
+
+ Experience + {save.character.experience} XP +
+
+
+
+ Bulldrome Kills + {bulldromeKills} +
+
+ Raw Bulldrome Coins + {bulldromeDrops} +
+
+ Yian Kut Ku Kills + {yianKills} +
+
+ Yian Kut Ku Scales + {yianDrops} +
+
+ IWT2 Inventory + {save.inventory.length} slots +
+
+
+
+ ) +} + +export function Iwt2CustomizeCharacterScreen({ + onBack, + save, +}: { + onBack: () => void + save: Iwt2Save +}) { + const actions = useMemo(() => [ + { + key: 'class', + label: 'Healer Style', + detail: 'Current vertical slice uses Field Medic abilities based on IWT1 dungeon spells.', + value: 'Field Medic', + onConfirm: () => {}, + }, + { + key: 'appearance', + label: 'Armor Palette', + detail: 'Character cosmetics will stay scoped to IWT2 save data.', + value: 'Default', + onConfirm: () => {}, + }, + { + key: 'name', + label: 'Character Name', + detail: 'Name editing shell for the IWT2 hunter profile.', + value: save.character.name, + onConfirm: () => {}, + }, + ], [save.character.name]) + + return ( + + + + ) +} + +export function Iwt2CloudSaveScreen({ + onBack, + save, +}: { + onBack: () => void + save: Iwt2Save +}) { + const savedAt = new Date(save.updatedAt).toLocaleString() + const actions = useMemo(() => [ + { + key: 'status', + label: 'Cloud Save Slot', + detail: 'Local IWT2 save is isolated and ready for cloud sync integration.', + value: 'Not linked', + onConfirm: () => {}, + }, + { + key: 'last-save', + label: 'Last Local Save', + detail: savedAt, + value: 'Local', + onConfirm: () => {}, + }, + ], [savedAt]) + + return ( + + + + ) +} + +export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) { + const { + controllerIconStyle, + directPartyTargeting, + setControllerIconStyle, + setDirectPartyTargeting, + } = useInput() + const actions = useMemo(() => [ + { + key: 'direct-targeting', + label: 'Target Party Members With Bindings', + detail: 'When enabled, party frames show direct target button icons instead of D-pad cycling.', + value: directPartyTargeting ? 'On' : 'Off', + onConfirm: () => setDirectPartyTargeting(!directPartyTargeting), + }, + ...CONTROLLER_ICON_OPTIONS.map((style) => ({ + key: `icons-${style}`, + label: `${CONTROLLER_ICON_LABELS[style]} Button Icons`, + detail: 'Changes controller glyphs shown on IWT2 frames and spell controls.', + value: controllerIconStyle === style ? 'Selected' : '', + onConfirm: () => setControllerIconStyle(style), + })), + ], [ + controllerIconStyle, + directPartyTargeting, + setControllerIconStyle, + setDirectPartyTargeting, + ]) + + return ( + +
+ +
+ {CONTROLLER_ICON_LABELS[controllerIconStyle]} + +
+
+
+ ) +} diff --git a/src/modes/iwt2/sim/arena.ts b/src/modes/iwt2/sim/arena.ts new file mode 100644 index 0000000..bbd10c0 --- /dev/null +++ b/src/modes/iwt2/sim/arena.ts @@ -0,0 +1,646 @@ +import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' +import { IWT2_CLASS_METADATA } from '../content/classes' +import type { + Iwt2ArenaEvent, + Iwt2ArenaInput, + Iwt2ArenaState, + Iwt2EntityId, + Iwt2GroundHazardState, + Iwt2HostileAddState, + Iwt2PartyAiRole, + Iwt2PartyEntityId, + Iwt2PartyEntityState, + Iwt2ProjectileEntityState, + Iwt2StatusState, +} from './types' +import type { Iwt2PlayerClassId } from '../content/classes' +import { tickBoss } from './bossAi' +import { separateCircles } from './collision' +import { canPartyMemberHitTarget, tickPartyMember } from './partyAi' +import { addFirePuddle, createHazardIndicators, tickGroundHazards } from './hazards' +import { applyPartyDamageInShape } from './mechanics' +import { + clampVec2ToArena, + distanceVec2, + normalizeVec2, + scaleVec2, + subtractVec2, +} from './vector' + +const DEFAULT_ARENA_WIDTH = 960 +const DEFAULT_ARENA_HEIGHT = 540 +const MAX_DT = 1 / 15 +const MAX_EVENTS = 80 +const HEALER_MANA_REGEN_PER_SECOND = 3 +const BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS = 0.22 + +type InitialPartyMember = { + id: Iwt2PartyEntityId + classId: Iwt2PlayerClassId + aiRole: Iwt2PartyAiRole + x: number + y: number + preferredOffset: { x: number; y: number } + decisionOffset: number +} + +const INITIAL_PARTY: InitialPartyMember[] = [ + { id: 'player-healer', classId: 'healer', aiRole: 'player', x: 250, y: 250, preferredOffset: { x: -260, y: 0 }, decisionOffset: 0 }, + { id: 'paladin-tank', classId: 'paladin', aiRole: 'tank', x: 470, y: 250, preferredOffset: { x: -58, y: 6 }, decisionOffset: 0.08 }, + { id: 'ranger', classId: 'ranger', aiRole: 'ranged', x: 280, y: 140, preferredOffset: { x: -230, y: -92 }, decisionOffset: 0.18 }, + { id: 'mage', classId: 'mage', aiRole: 'ranged', x: 300, y: 365, preferredOffset: { x: -190, y: 98 }, decisionOffset: 0.32 }, + { id: 'rogue', classId: 'rogue', aiRole: 'flanker', x: 520, y: 195, preferredOffset: { x: 24, y: -58 }, decisionOffset: 0.12 }, + { id: 'warrior', classId: 'warrior', aiRole: 'melee', x: 515, y: 310, preferredOffset: { x: -18, y: 64 }, decisionOffset: 0.24 }, +] + +export function createInitialIwt2ArenaState(bossId: Iwt2BossId = 'bulldrome'): Iwt2ArenaState { + const bossMetadata = IWT2_BOSS_METADATA[bossId] + return { + schemaVersion: 1, + time: 0, + bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT }, + party: INITIAL_PARTY.map(createPartyMember), + projectiles: [], + hostileAdds: [], + hazards: [], + indicators: [], + boss: { + id: bossId, + kind: 'boss', + bossId, + position: { x: 640, y: 250 }, + velocity: { x: 0, y: 0 }, + facing: { x: -1, y: 0 }, + radius: bossMetadata.radius, + health: bossMetadata.maxHealth, + maxHealth: bossMetadata.maxHealth, + meleeCooldownRemaining: 0.6, + chargeCooldownRemaining: bossId === 'bulldrome' ? 2 : 0, + chargeCount: 0, + attackPhase: 'idle', + phaseSecondsRemaining: 0, + chargeStart: { x: 640, y: 250 }, + chargeEnd: { x: 640, y: 250 }, + chargeHitEntityIds: [], + slamApplied: false, + wallContactSeconds: 0, + relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 }, + fireballCooldownRemaining: bossId === 'yian-kut-ku' ? 1.2 : 0, + fireballTarget: { x: 320, y: 250 }, + birdWaveThresholdsTriggered: [], + }, + nextEventId: 1, + nextProjectileId: 1, + nextAddId: 1, + nextHazardId: 1, + events: [], + } +} + +export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { + const step = Math.max(0, Math.min(dt, MAX_DT)) + if (step <= 0) return { ...state, events: [...state.events] } + + const inputMove = { + x: clampInputAxis(input.moveX), + y: clampInputAxis(input.moveY), + } + const baseState = { + ...state, + time: state.time + step, + party: state.party.map((member) => tickPartyMember(member, state, inputMove, step)), + events: [], + } + const separatedState = { + ...baseState, + party: separatePartyFromBoss(baseState.party, baseState), + } + const bossResult = tickBoss(separatedState, step) + const projectileResult = advanceProjectiles( + [...separatedState.projectiles, ...(bossResult.projectiles ?? [])], + bossResult.party, + bossResult.boss, + bossResult.hostileAdds ?? separatedState.hostileAdds, + bossResult.hazards ?? separatedState.hazards, + bossResult.nextHazardId ?? state.nextHazardId, + bossResult.nextProjectileId ?? state.nextProjectileId, + separatedState.bounds, + separatedState.time, + step, + ) + const hazardResult = tickGroundHazards({ + dt: step, + hazards: projectileResult.hazards, + party: projectileResult.party, + time: separatedState.time, + }) + const damageResult = applyPartyAttacks( + hazardResult.party, + projectileResult.boss, + projectileResult.hostileAdds, + separatedState.time, + projectileResult.nextProjectileId, + ) + const hotResult = tickPartyHotEffects(damageResult.party, step, separatedState.time) + const finalParty = regeneratePartyMana(hotResult.party, step) + const nextEvents = assignEventIds( + [...bossResult.events, ...projectileResult.events, ...hazardResult.events, ...damageResult.events, ...hotResult.events], + state.nextEventId, + ) + return { + ...separatedState, + boss: damageResult.boss, + indicators: [...bossResult.indicators, ...createHazardIndicators(hazardResult.hazards)], + party: finalParty, + hostileAdds: damageResult.hostileAdds, + hazards: hazardResult.hazards, + projectiles: [...projectileResult.projectiles, ...damageResult.projectiles], + nextProjectileId: damageResult.nextProjectileId, + nextAddId: bossResult.nextAddId ?? state.nextAddId, + nextHazardId: projectileResult.nextHazardId, + nextEventId: state.nextEventId + nextEvents.length, + events: [...state.events, ...nextEvents].slice(-MAX_EVENTS), + } +} + +function tickPartyHotEffects( + party: Iwt2PartyEntityState[], + dt: number, + time: number, +): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + const events: Iwt2ArenaEvent[] = [] + const nextParty = party.map((member) => { + if (member.hotEffects.length === 0) return member + + let health = member.health + const hotEffects = member.hotEffects.flatMap((effect) => { + const remainingSeconds = effect.remainingSeconds - dt + if (remainingSeconds <= 0) return [] + let nextTickInSeconds = effect.nextTickInSeconds - dt + if (member.health > 0 && nextTickInSeconds <= 0) { + const before = health + health = Math.min(member.maxHealth, health + effect.power) + const healed = health - before + if (healed > 0) { + events.push({ + id: 0, + time, + type: 'partyHealed', + sourceId: 'player-healer', + targetId: member.id, + value: healed, + }) + } + nextTickInSeconds += effect.tickIntervalSeconds + } + return [{ + ...effect, + nextTickInSeconds, + remainingSeconds, + }] + }) + + return { + ...member, + health, + hotEffects, + } + }) + return { party: nextParty, events } +} + +function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2PartyEntityState[] { + return party.map((member) => { + if (member.maxMana <= 0 || member.health <= 0 || member.mana >= member.maxMana) return member + return { + ...member, + mana: Math.min(member.maxMana, member.mana + HEALER_MANA_REGEN_PER_SECOND * dt), + } + }) +} + +function createPartyMember(initial: InitialPartyMember): Iwt2PartyEntityState { + const metadata = IWT2_CLASS_METADATA[initial.classId] + return { + id: initial.id, + kind: 'party', + classId: initial.classId, + aiRole: initial.aiRole, + position: { x: initial.x, y: initial.y }, + velocity: { x: 0, y: 0 }, + facing: { x: 1, y: 0 }, + radius: metadata.radius, + health: metadata.maxHealth, + maxHealth: metadata.maxHealth, + shield: 0, + mana: initial.classId === 'healer' ? 100 : 0, + maxMana: initial.classId === 'healer' ? 100 : 0, + damageDone: 0, + attackCooldownRemaining: initial.classId === 'healer' ? 0 : metadata.attackCooldown * 0.35, + castSecondsRemaining: 0, + attackReady: false, + status: createEmptyStatus(), + hotEffects: [], + preferredOffset: { ...initial.preferredOffset }, + decisionSecondsRemaining: initial.decisionOffset, + } +} + +function createEmptyStatus(): Iwt2StatusState { + return { + stunnedSeconds: 0, + knockedDownSeconds: 0, + invulnerableSeconds: 0, + } +} + +function clampInputAxis(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(1, Math.max(-1, value)) +} + +function separatePartyFromBoss(party: Iwt2PartyEntityState[], state: Iwt2ArenaState): Iwt2PartyEntityState[] { + return party.map((member) => { + if (member.health <= 0) return member + const position = separateCircles( + { position: member.position, radius: member.radius }, + { position: state.boss.position, radius: state.boss.radius }, + state.bounds, + ) + return { ...member, position } + }) +} + +function advanceProjectiles( + projectiles: Iwt2ProjectileEntityState[], + party: Iwt2PartyEntityState[], + boss: Iwt2ArenaState['boss'], + hostileAdds: Iwt2HostileAddState[], + hazards: Iwt2GroundHazardState[], + nextHazardId: number, + nextProjectileId: number, + bounds: Iwt2ArenaState['bounds'], + time: number, + dt: number, +): { + boss: Iwt2ArenaState['boss'] + party: Iwt2PartyEntityState[] + hostileAdds: Iwt2HostileAddState[] + hazards: Iwt2GroundHazardState[] + nextHazardId: number + nextProjectileId: number + projectiles: Iwt2ProjectileEntityState[] + events: Iwt2ArenaEvent[] +} { + const events: Iwt2ArenaEvent[] = [] + let nextBoss = boss + let nextParty = party + let nextHostileAdds = hostileAdds + let nextHazards = hazards + let nextHazardIdValue = nextHazardId + const nextProjectiles: Iwt2ProjectileEntityState[] = [] + + for (const projectile of projectiles) { + if (projectile.owner === 'boss') { + const result = advanceBossProjectile({ + bounds, + dt, + hazards: nextHazards, + nextHazardId: nextHazardIdValue, + party: nextParty, + projectile, + time, + }) + nextParty = result.party + nextHazards = result.hazards + nextHazardIdValue = result.nextHazardId + events.push(...result.events) + if (result.projectile) nextProjectiles.push(result.projectile) + continue + } + + if (nextBoss.health <= 0 && nextHostileAdds.every((add) => add.health <= 0)) continue + const nextPosition = { + x: projectile.position.x + projectile.velocity.x * dt, + y: projectile.position.y + projectile.velocity.y * dt, + } + const remainingSeconds = projectile.remainingSeconds - dt + const hitAdd = nextHostileAdds.find((add) => ( + add.health > 0 + && distanceVec2(nextPosition, add.position) <= add.radius + projectile.radius + )) + if (remainingSeconds <= 0) continue + if (hitAdd) { + const damage = Math.min(projectile.damage, hitAdd.health) + nextHostileAdds = nextHostileAdds.map((add) => add.id === hitAdd.id + ? { ...add, health: Math.max(0, add.health - damage) } + : add) + nextParty = addDamageDone(nextParty, projectile.sourceId, damage) + events.push({ + id: 0, + time, + type: 'bossDamaged', + sourceId: projectile.sourceId, + targetId: hitAdd.id, + value: damage, + }) + if (hitAdd.health - damage <= 0) { + events.push({ + id: 0, + time, + type: 'entityDefeated', + sourceId: projectile.sourceId, + targetId: hitAdd.id, + }) + } + continue + } + + if (distanceVec2(nextPosition, nextBoss.position) <= nextBoss.radius + projectile.radius) { + const damage = Math.min(projectile.damage, nextBoss.health) + nextBoss = { + ...nextBoss, + health: Math.max(0, nextBoss.health - damage), + } + nextParty = addDamageDone(nextParty, projectile.sourceId, damage) + events.push({ + id: 0, + time, + type: 'bossDamaged', + sourceId: projectile.sourceId, + targetId: nextBoss.id, + value: damage, + }) + if (nextBoss.health <= 0) { + events.push({ + id: 0, + time, + type: 'entityDefeated', + sourceId: projectile.sourceId, + targetId: nextBoss.id, + }) + } + continue + } + nextProjectiles.push({ + ...projectile, + position: nextPosition, + remainingSeconds, + }) + } + + return { + boss: nextBoss, + party: nextParty, + hostileAdds: nextHostileAdds, + hazards: nextHazards, + nextHazardId: nextHazardIdValue, + nextProjectileId, + projectiles: nextProjectiles, + events, + } +} + +function advanceBossProjectile({ + bounds, + dt, + hazards, + nextHazardId, + party, + projectile, + time, +}: { + bounds: Iwt2ArenaState['bounds'] + dt: number + hazards: Iwt2GroundHazardState[] + nextHazardId: number + party: Iwt2PartyEntityState[] + projectile: Iwt2ProjectileEntityState + time: number +}): { + party: Iwt2PartyEntityState[] + hazards: Iwt2GroundHazardState[] + nextHazardId: number + projectile?: Iwt2ProjectileEntityState + events: Iwt2ArenaEvent[] +} { + const events: Iwt2ArenaEvent[] = [] + const remainingSeconds = projectile.remainingSeconds - dt + if (remainingSeconds <= 0) return { party, hazards, nextHazardId, events } + + let nextParty = party + let nextHazards = hazards + let nextHazardIdValue = nextHazardId + let bounced = false + const bounceCooldownSeconds = Math.max(0, (projectile.bounceCooldownSeconds ?? 0) - dt) + const canBounce = bounceCooldownSeconds <= 0 + let velocity = { ...projectile.velocity } + let position = { + x: projectile.position.x + projectile.velocity.x * dt, + y: projectile.position.y + projectile.velocity.y * dt, + } + + if (canBounce && (position.x - projectile.radius <= 0 || position.x + projectile.radius >= bounds.width)) { + velocity = { ...velocity, x: -velocity.x } + position = clampVec2ToArena(position, projectile.radius, bounds) + bounced = true + } + if (canBounce && (position.y - projectile.radius <= 0 || position.y + projectile.radius >= bounds.height)) { + velocity = { ...velocity, y: -velocity.y } + position = clampVec2ToArena(position, projectile.radius, bounds) + bounced = true + } + + const hitMember = canBounce + ? nextParty.find((member) => ( + member.health > 0 + && distanceVec2(position, member.position) <= projectile.radius + member.radius + )) + : undefined + if (hitMember) { + const result = applyPartyDamageInShape(nextParty, { + kind: 'circle', + position: hitMember.position, + radius: hitMember.radius + projectile.radius, + }, { + damage: projectile.damage, + sourceId: projectile.sourceId, + time, + }) + nextParty = result.party + events.push(...result.events) + const away = normalizeVec2(subtractVec2(position, hitMember.position)) + const fallback = normalizeVec2(scaleVec2(projectile.velocity, -1)) + const direction = away.x === 0 && away.y === 0 ? fallback : away + const speed = Math.max(1, Math.hypot(projectile.velocity.x, projectile.velocity.y)) + velocity = scaleVec2(direction, speed) + position = clampVec2ToArena({ + x: hitMember.position.x + direction.x * (hitMember.radius + projectile.radius + 2), + y: hitMember.position.y + direction.y * (hitMember.radius + projectile.radius + 2), + }, projectile.radius, bounds) + bounced = true + } + + if (bounced) { + const puddle = addFirePuddle({ + damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!, + duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!, + hazards: nextHazards, + nextHazardId: nextHazardIdValue, + position: hitMember?.position ?? position, + radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!, + sourceId: projectile.sourceId, + time, + }) + nextHazards = puddle.hazards + nextHazardIdValue = puddle.nextHazardId + } + + const bouncesRemaining = bounced + ? (projectile.bouncesRemaining ?? 0) - 1 + : projectile.bouncesRemaining + if (bouncesRemaining !== undefined && bouncesRemaining < 0) { + return { party: nextParty, hazards: nextHazards, nextHazardId: nextHazardIdValue, events } + } + + return { + events, + hazards: nextHazards, + nextHazardId: nextHazardIdValue, + party: nextParty, + projectile: { + ...projectile, + bouncesRemaining, + bounceCooldownSeconds: bounced ? BOSS_PROJECTILE_BOUNCE_COOLDOWN_SECONDS : bounceCooldownSeconds, + position, + remainingSeconds, + velocity, + }, + } +} + +function applyPartyAttacks( + party: Iwt2PartyEntityState[], + boss: Iwt2ArenaState['boss'], + hostileAdds: Iwt2HostileAddState[], + time: number, + nextProjectileId: number, +): { + boss: Iwt2ArenaState['boss'] + party: Iwt2PartyEntityState[] + hostileAdds: Iwt2HostileAddState[] + projectiles: Iwt2ProjectileEntityState[] + nextProjectileId: number + events: Iwt2ArenaEvent[] +} { + const events: Iwt2ArenaEvent[] = [] + const projectiles: Iwt2ProjectileEntityState[] = [] + let nextBoss = boss + let nextHostileAdds = hostileAdds + let projectileId = nextProjectileId + const nextParty = party.map((member) => { + const target = getPriorityAttackTarget(member, nextBoss, nextHostileAdds) + if (!target) return member + if (!canPartyMemberHitTarget(member, target.position)) return member + const metadata = IWT2_CLASS_METADATA[member.classId] + if (metadata.projectileSpeed > 0) { + if (!member.attackReady) return member + const direction = normalizeVec2(subtractVec2(target.position, member.position)) + projectiles.push({ + id: `projectile-${projectileId}`, + sourceId: member.id, + owner: 'party', + classId: member.classId, + projectileKind: member.classId === 'mage' ? 'magic' : 'arrow', + color: metadata.accentColor, + position: { + x: member.position.x + direction.x * (member.radius + 4), + y: member.position.y + direction.y * (member.radius + 4), + }, + velocity: scaleVec2(direction, metadata.projectileSpeed), + radius: member.classId === 'mage' ? 7 : 4, + damage: metadata.attackDamage, + remainingSeconds: 1.2, + }) + projectileId += 1 + return { + ...member, + attackCooldownRemaining: metadata.attackCooldown, + attackReady: false, + } + } + if (member.attackCooldownRemaining > 0) return member + const damage = Math.min(metadata.attackDamage, target.health) + if (target.kind === 'boss') { + nextBoss = { ...nextBoss, health: Math.max(0, nextBoss.health - damage) } + } else { + nextHostileAdds = nextHostileAdds.map((add) => add.id === target.id + ? { ...add, health: Math.max(0, add.health - damage) } + : add) + } + events.push({ + id: 0, + time, + type: 'bossDamaged', + sourceId: member.id, + targetId: target.id, + value: damage, + }) + if (target.health - damage <= 0) { + events.push({ + id: 0, + time, + type: 'entityDefeated', + sourceId: member.id, + targetId: target.id, + }) + return { + ...member, + damageDone: member.damageDone + damage, + attackCooldownRemaining: metadata.attackCooldown, + } + } + return { + ...member, + damageDone: member.damageDone + damage, + attackCooldownRemaining: metadata.attackCooldown, + } + }) + return { + boss: nextBoss, + party: nextParty, + hostileAdds: nextHostileAdds.filter((add) => add.health > 0), + projectiles, + nextProjectileId: projectileId, + events, + } +} + +function getPriorityAttackTarget( + member: Iwt2PartyEntityState, + boss: Iwt2ArenaState['boss'], + hostileAdds: Iwt2HostileAddState[], +): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined { + if (member.health <= 0) return undefined + const livingAdds = hostileAdds.filter((add) => add.health > 0) + if (livingAdds.length > 0) { + return livingAdds.reduce((best, add) => ( + distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best + ), livingAdds[0]) + } + return boss.health > 0 ? boss : undefined +} + +function addDamageDone( + party: Iwt2PartyEntityState[], + sourceId: Iwt2EntityId, + damage: number, +): Iwt2PartyEntityState[] { + return party.map((member) => member.id === sourceId + ? { ...member, damageDone: member.damageDone + damage } + : member) +} + +function assignEventIds(events: Iwt2ArenaEvent[], firstId: number): Iwt2ArenaEvent[] { + return events.map((event, index) => ({ ...event, id: firstId + index })) +} diff --git a/src/modes/iwt2/sim/arenaState.ts b/src/modes/iwt2/sim/arenaState.ts new file mode 100644 index 0000000..3530c01 --- /dev/null +++ b/src/modes/iwt2/sim/arenaState.ts @@ -0,0 +1,148 @@ +import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' +import { IWT2_CLASS_METADATA } from '../content/classes' +import { + createInitialIwt2ArenaState as createCoreIwt2ArenaState, + tickIwt2Arena as tickCoreIwt2Arena, +} from './arena' +import type { + Iwt2ArenaIndicator, + Iwt2ArenaInput, + Iwt2ArenaState as Iwt2CoreArenaState, + Iwt2BossEntityState, + Iwt2HostileAddState, + Iwt2PartyEntityState, +} from './types' + +export type Iwt2ArenaEntityKind = 'player' | 'party' | 'boss' | 'projectile' | 'hostileAdd' + +export type Iwt2ArenaEntity = { + id: string + kind: Iwt2ArenaEntityKind + icon: string + color: string + x: number + y: number + radius: number + health: number + maxHealth: number + stunnedFor: number +} + +export type Iwt2ArenaTelegraph = + | { + kind: 'charge' + x: number + y: number + width: number + height: number + active: boolean + } + | { + kind: 'slam' + x: number + y: number + radius: number + active: boolean + } + +export type Iwt2ArenaState = Iwt2CoreArenaState & { + arena: { + width: number + height: number + } + entities: Iwt2ArenaEntity[] + telegraphs: Iwt2ArenaTelegraph[] +} + +export function createInitialIwt2ArenaState(bossId?: Iwt2BossId): Iwt2ArenaState { + return decorateArenaState(createCoreIwt2ArenaState(bossId)) +} + +export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState { + return decorateArenaState(tickCoreIwt2Arena(state, input, dt)) +} + +export function decorateArenaState(state: Iwt2CoreArenaState): Iwt2ArenaState { + return { + ...state, + arena: { ...state.bounds }, + entities: [ + ...state.party.map(toArenaEntity), + ...state.hostileAdds.map(toHostileAddArenaEntity), + toBossArenaEntity(state.boss), + ], + telegraphs: createTelegraphs(state.indicators), + } +} + +function toHostileAddArenaEntity(add: Iwt2HostileAddState): Iwt2ArenaEntity { + return { + id: add.id, + kind: 'hostileAdd', + icon: 'v', + color: '#f0b84f', + x: add.position.x, + y: add.position.y, + radius: add.radius, + health: add.health, + maxHealth: add.maxHealth, + stunnedFor: 0, + } +} + +function toArenaEntity(member: Iwt2PartyEntityState): Iwt2ArenaEntity { + const metadata = IWT2_CLASS_METADATA[member.classId] + return { + id: member.id, + kind: member.aiRole === 'player' ? 'player' : 'party', + icon: metadata.icon, + color: metadata.color, + x: member.position.x, + y: member.position.y, + radius: member.radius, + health: member.health, + maxHealth: member.maxHealth, + stunnedFor: Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds), + } +} + +function toBossArenaEntity(boss: Iwt2BossEntityState): Iwt2ArenaEntity { + const metadata = IWT2_BOSS_METADATA[boss.bossId] + return { + id: boss.id, + kind: 'boss', + icon: metadata.icon, + color: metadata.color, + x: boss.position.x, + y: boss.position.y, + radius: boss.radius, + health: boss.health, + maxHealth: boss.maxHealth, + stunnedFor: 0, + } +} + +function createTelegraphs(indicators: Iwt2ArenaIndicator[]): Iwt2ArenaTelegraph[] { + return indicators.flatMap((indicator): Iwt2ArenaTelegraph[] => { + if (indicator.kind === 'lane') { + return [{ + active: indicator.phase === 'active', + height: indicator.width * 2, + kind: 'charge' as const, + width: Math.max(16, Math.hypot(indicator.end.x - indicator.start.x, indicator.end.y - indicator.start.y)), + x: Math.min(indicator.start.x, indicator.end.x), + y: Math.min(indicator.start.y, indicator.end.y) - indicator.width, + }] + } + if (indicator.kind === 'circle') { + return [{ + active: indicator.phase === 'active', + kind: 'slam' as const, + radius: indicator.radius, + x: indicator.position.x, + y: indicator.position.y, + }] + } + return [] + }) +} diff --git a/src/modes/iwt2/sim/bossAi.ts b/src/modes/iwt2/sim/bossAi.ts new file mode 100644 index 0000000..b383b63 --- /dev/null +++ b/src/modes/iwt2/sim/bossAi.ts @@ -0,0 +1,378 @@ +import { BULLDROME_BOSS_METADATA } from '../content/bosses' +import type { + Iwt2ArenaEvent, + Iwt2ArenaIndicator, + Iwt2ArenaState, + Iwt2BossEntityState, + Iwt2EntityId, + Iwt2GroundHazardState, + Iwt2HostileAddState, + Iwt2PartyEntityState, + Iwt2ProjectileEntityState, + Iwt2Vec2, +} from './types' +import { tickYianKutKu } from './yianKutKuAi' +import { + applyPartyDamageInShape, + createArenaEvent, + createCircleIndicator, + createLaneIndicator, + createLinearMovementPlan, + indicatorPhaseFromAttack, +} from './mechanics' +import { + clampVec2ToArena, + distanceVec2, + moveToward, + scaleVec2, + subtractVec2, + withFallbackFacing, +} from './vector' + +export type Iwt2BossTickResult = { + boss: Iwt2BossEntityState + party: Iwt2PartyEntityState[] + hostileAdds?: Iwt2HostileAddState[] + hazards?: Iwt2GroundHazardState[] + projectiles?: Iwt2ProjectileEntityState[] + events: Iwt2ArenaEvent[] + indicators: Iwt2ArenaIndicator[] + nextAddId?: number + nextHazardId?: number + nextProjectileId?: number +} + +export function tickBoss(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { + if (state.boss.bossId === 'yian-kut-ku') return tickYianKutKu(state, dt) + return tickBulldrome(state, dt) +} + +export function tickBulldrome(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult { + const events: Iwt2ArenaEvent[] = [] + const target = getBossTarget(state.party) + let party = state.party + let boss = { + ...state.boss, + meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), + chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt), + phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), + } + const wallContactSeconds = isBossNearWall(boss, state) + ? boss.wallContactSeconds + dt + : Math.max(0, boss.wallContactSeconds - dt * 2) + boss = { ...boss, wallContactSeconds } + + if (boss.health <= 0 || !target) { + return withBulldromeIndicators({ boss: { ...boss, velocity: { x: 0, y: 0 } }, party, events }) + } + + if (boss.attackPhase === 'relocating') { + const nextPosition = clampVec2ToArena( + moveToward(boss.position, boss.relocateTarget, BULLDROME_BOSS_METADATA.moveSpeed * 1.45 * dt), + boss.radius, + state.bounds, + ) + const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0) + boss = { + ...boss, + position: nextPosition, + velocity, + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), + wallContactSeconds: isBossNearWall({ ...boss, position: nextPosition }, state) ? boss.wallContactSeconds : 0, + } + if (boss.phaseSecondsRemaining <= 0 || distanceVec2(nextPosition, boss.relocateTarget) <= 8) { + boss = { + ...boss, + attackPhase: 'idle', + phaseSecondsRemaining: 0, + chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.2), + velocity: { x: 0, y: 0 }, + } + } + return withBulldromeIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'chargeWindup') { + boss = { + ...boss, + velocity: { x: 0, y: 0 }, + facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing), + } + if (boss.phaseSecondsRemaining <= 0) boss = { ...boss, attackPhase: 'charging' } + return withBulldromeIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'charging') { + const nextPosition = clampVec2ToArena( + moveToward(boss.position, boss.chargeEnd, BULLDROME_BOSS_METADATA.chargeSpeed * dt), + boss.radius, + state.bounds, + ) + const hitResult = applyChargeHits(party, boss, boss.position, nextPosition, state.time + dt) + party = hitResult.party + events.push(...hitResult.events) + const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0) + boss = { + ...boss, + position: nextPosition, + velocity, + facing: withFallbackFacing(velocity, boss.facing), + chargeHitEntityIds: hitResult.hitEntityIds, + } + if (distanceVec2(nextPosition, boss.chargeEnd) <= 1) { + const needsSlam = boss.chargeCount % 3 === 0 + boss = { + ...boss, + attackPhase: needsSlam ? 'slamWindup' : 'chargeRecover', + phaseSecondsRemaining: needsSlam ? BULLDROME_BOSS_METADATA.slamWindup : 0.45, + slamApplied: false, + velocity: { x: 0, y: 0 }, + } + } + return withBulldromeIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'slamWindup') { + boss = { ...boss, velocity: { x: 0, y: 0 } } + if (boss.phaseSecondsRemaining <= 0) { + const slamResult = applySlam(party, boss, state.time + dt) + party = slamResult.party + events.push(...slamResult.events) + boss = { + ...boss, + attackPhase: 'slamRecover', + phaseSecondsRemaining: 0.5, + slamApplied: true, + } + } + return withBulldromeIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'chargeRecover' || boss.attackPhase === 'slamRecover') { + boss = { ...boss, velocity: { x: 0, y: 0 } } + if (boss.phaseSecondsRemaining <= 0) { + boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 } + } + return withBulldromeIndicators({ boss, party, events }) + } + + if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= 1.1) { + boss = { + ...boss, + attackPhase: 'relocating', + phaseSecondsRemaining: 1.8, + relocateTarget: relocationTarget(boss, state), + velocity: { x: 0, y: 0 }, + } + return withBulldromeIndicators({ boss, party, events }) + } + + if (boss.chargeCooldownRemaining <= 0) { + const charge = createLinearMovementPlan({ + bounds: state.bounds, + from: boss.position, + length: BULLDROME_BOSS_METADATA.chargeLength, + radius: boss.radius, + target: target.position, + }) + boss = { + ...boss, + attackPhase: 'chargeWindup', + phaseSecondsRemaining: BULLDROME_BOSS_METADATA.chargeWindup, + chargeCooldownRemaining: BULLDROME_BOSS_METADATA.chargeCooldown, + chargeCount: boss.chargeCount + 1, + chargeStart: { ...boss.position }, + chargeEnd: charge.end, + chargeHitEntityIds: [], + slamApplied: false, + facing: charge.direction, + velocity: { x: 0, y: 0 }, + } + events.push(createArenaEvent(state.nextEventId + events.length, state.time + dt, 'bossChargeStart', boss.id, target.id)) + return withBulldromeIndicators({ boss, party, events }) + } + + const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt, state.nextEventId + events.length) + party = meleeResult.party + events.push(...meleeResult.events) + boss = meleeResult.boss + + if (meleeResult.events.length > 0) return withBulldromeIndicators({ boss, party, events }) + + const nextPosition = clampVec2ToArena( + moveToward(boss.position, target.position, BULLDROME_BOSS_METADATA.moveSpeed * dt), + boss.radius, + state.bounds, + ) + const velocity = scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0) + boss = { + ...boss, + position: nextPosition, + velocity, + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), + } + return withBulldromeIndicators({ boss, party, events }) +} + +function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState) { + const margin = boss.radius + 10 + return ( + boss.position.x <= margin + || boss.position.x >= state.bounds.width - margin + || boss.position.y <= margin + || boss.position.y >= state.bounds.height - margin + ) +} + +function relocationTarget(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2 { + const center = { x: state.bounds.width * 0.58, y: state.bounds.height * 0.5 } + const awayFromWall = { + x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1, + y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1, + } + return { + x: Math.min(state.bounds.width - boss.radius, Math.max(boss.radius, center.x + awayFromWall.x * 70)), + y: Math.min(state.bounds.height - boss.radius, Math.max(boss.radius, center.y + awayFromWall.y * 54)), + } +} + +function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined { + const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0) + if (livingTank) return livingTank + return party.find((member) => member.health > 0) +} + +function maybeApplyMelee( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + target: Iwt2PartyEntityState, + time: number, + firstEventId: number, +): Omit { + if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } + if (distanceVec2(boss.position, target.position) > BULLDROME_BOSS_METADATA.meleeRange + target.radius) { + return { boss, party, events: [] } + } + const nextParty = party.map((member) => { + if (member.id !== target.id) return member + return { ...member, health: Math.max(0, member.health - BULLDROME_BOSS_METADATA.meleeDamage) } + }) + const events = [ + createArenaEvent(firstEventId, time, 'partyDamaged', boss.id, target.id, BULLDROME_BOSS_METADATA.meleeDamage), + ] + if (target.health > 0 && target.health - BULLDROME_BOSS_METADATA.meleeDamage <= 0) { + events.push(createArenaEvent(firstEventId + 1, time, 'entityDefeated', boss.id, target.id)) + } + return { + boss: { ...boss, meleeCooldownRemaining: BULLDROME_BOSS_METADATA.meleeCooldown, velocity: { x: 0, y: 0 } }, + party: nextParty, + events, + } +} + +function applyChargeHits( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + start: Iwt2Vec2, + end: Iwt2Vec2, + time: number, +): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } { + const result = applyPartyDamageInShape( + party, + { + kind: 'lane', + start, + end, + width: boss.radius, + }, + { + damage: BULLDROME_BOSS_METADATA.chargeDamage, + damageEventType: 'bossChargeHit', + excludedEntityIds: boss.chargeHitEntityIds, + knockdownSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds, + sourceId: boss.id, + stunSeconds: BULLDROME_BOSS_METADATA.chargeStunSeconds, + time, + }, + ) + return { + ...result, + hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds], + } +} + +function applySlam( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + time: number, +): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + const result = applyPartyDamageInShape( + party, + { + kind: 'circle', + position: boss.position, + radius: BULLDROME_BOSS_METADATA.slamRadius, + }, + { + damage: BULLDROME_BOSS_METADATA.slamDamage, + knockdownSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds, + sourceId: boss.id, + stunSeconds: BULLDROME_BOSS_METADATA.slamStunSeconds, + time, + }, + ) + return { + party: result.party, + events: [ + createArenaEvent(0, time, 'bossSlam', boss.id, undefined, BULLDROME_BOSS_METADATA.slamRadius), + ...result.events, + ], + } +} + +function withBulldromeIndicators( + result: Omit, +): Iwt2BossTickResult { + return { + ...result, + indicators: createBulldromeIndicators(result.boss), + } +} + +function createBulldromeIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] { + const indicators: Iwt2ArenaIndicator[] = [] + if ( + boss.attackPhase === 'chargeWindup' + || boss.attackPhase === 'charging' + || boss.attackPhase === 'chargeRecover' + ) { + indicators.push(createLaneIndicator({ + color: boss.attackPhase === 'charging' ? '#f05b4f' : '#f1c663', + end: boss.chargeEnd, + id: `${boss.id}:charge-lane`, + mechanicId: 'bulldrome-charge', + phase: indicatorPhaseFromAttack( + boss.attackPhase === 'chargeWindup', + boss.attackPhase === 'charging', + ), + sourceId: boss.id, + start: boss.chargeStart, + width: boss.radius, + })) + } + if (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover') { + indicators.push(createCircleIndicator({ + color: '#ff7f50', + id: `${boss.id}:slam-circle`, + mechanicId: 'bulldrome-slam', + phase: indicatorPhaseFromAttack( + boss.attackPhase === 'slamWindup', + boss.attackPhase === 'slamRecover', + ), + position: boss.position, + radius: BULLDROME_BOSS_METADATA.slamRadius, + sourceId: boss.id, + })) + } + return indicators +} diff --git a/src/modes/iwt2/sim/collision.ts b/src/modes/iwt2/sim/collision.ts new file mode 100644 index 0000000..cd41857 --- /dev/null +++ b/src/modes/iwt2/sim/collision.ts @@ -0,0 +1,46 @@ +import type { Iwt2ArenaBounds, Iwt2Circle, Iwt2Vec2 } from './types' +import { + addVec2, + clamp, + clampVec2ToArena, + distanceVec2, + dotVec2, + lengthSqVec2, + normalizeVec2, + scaleVec2, + subtractVec2, +} from './vector' + +export function circlesOverlap(a: Iwt2Circle, b: Iwt2Circle): boolean { + const radiusSum = a.radius + b.radius + return distanceVec2(a.position, b.position) <= radiusSum +} + +export function distanceToSegment(point: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): number { + const segment = subtractVec2(end, start) + const segmentLengthSq = lengthSqVec2(segment) + if (segmentLengthSq <= 0.0001) return distanceVec2(point, start) + const pointOffset = subtractVec2(point, start) + const t = clamp(dotVec2(pointOffset, segment) / segmentLengthSq, 0, 1) + const projection = addVec2(start, scaleVec2(segment, t)) + return distanceVec2(point, projection) +} + +export function circleIntersectsSegment( + circle: Iwt2Circle, + start: Iwt2Vec2, + end: Iwt2Vec2, + width: number, +): boolean { + return distanceToSegment(circle.position, start, end) <= circle.radius + width +} + +export function separateCircles(moving: Iwt2Circle, fixed: Iwt2Circle, bounds: Iwt2ArenaBounds): Iwt2Vec2 { + const offset = subtractVec2(moving.position, fixed.position) + const minDistance = moving.radius + fixed.radius + const distanceSq = lengthSqVec2(offset) + if (distanceSq >= minDistance * minDistance) return moving.position + const direction = distanceSq <= 0.0001 ? { x: 1, y: 0 } : normalizeVec2(offset) + const resolved = addVec2(fixed.position, scaleVec2(direction, minDistance)) + return clampVec2ToArena(resolved, moving.radius, bounds) +} diff --git a/src/modes/iwt2/sim/hazards.ts b/src/modes/iwt2/sim/hazards.ts new file mode 100644 index 0000000..da6478a --- /dev/null +++ b/src/modes/iwt2/sim/hazards.ts @@ -0,0 +1,162 @@ +import type { + Iwt2ArenaEvent, + Iwt2ArenaIndicator, + Iwt2EntityId, + Iwt2GroundHazardState, + Iwt2PartyEntityState, + Iwt2Vec2, +} from './types' +import { + applyPartyDamageInShape, + createCircleIndicator, +} from './mechanics' + +const MAX_FIRE_PUDDLES = 8 +const FIRE_PUDDLE_TICK_SECONDS = 0.55 +const FIRE_PUDDLE_FADE_SECONDS = 0.8 +const FIRE_PUDDLE_MERGE_DISTANCE = 28 + +export function addFirePuddle({ + damage, + duration, + hazards, + nextHazardId, + position, + radius, + sourceId, + time, +}: { + damage: number + duration: number + hazards: Iwt2GroundHazardState[] + nextHazardId: number + position: Iwt2Vec2 + radius: number + sourceId: Iwt2EntityId + time: number +}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } { + const overlappingIndex = hazards.findIndex((hazard) => ( + hazard.hazardKind === 'firePuddle' + && hazard.remainingSeconds > hazard.fadeSeconds + && Math.hypot(hazard.position.x - position.x, hazard.position.y - position.y) <= FIRE_PUDDLE_MERGE_DISTANCE + )) + if (overlappingIndex >= 0) { + return { + hazards: hazards.map((hazard, index) => index === overlappingIndex + ? { + ...hazard, + damage: Math.max(hazard.damage, damage), + position: { + x: (hazard.position.x + position.x) / 2, + y: (hazard.position.y + position.y) / 2, + }, + radius: Math.max(hazard.radius, radius), + remainingSeconds: Math.max(hazard.remainingSeconds, duration), + } + : hazard), + nextHazardId, + } + } + + const nextHazard: Iwt2GroundHazardState = { + id: `fire-puddle-${nextHazardId}`, + kind: 'groundHazard', + hazardKind: 'firePuddle', + sourceId, + position: { ...position }, + radius, + damage, + remainingSeconds: duration, + fadeSeconds: FIRE_PUDDLE_FADE_SECONDS, + nextDamageAt: time + 0.12, + } + return { + hazards: capFirePuddles([...hazards, nextHazard]), + nextHazardId: nextHazardId + 1, + } +} + +export function tickGroundHazards({ + dt, + hazards, + party, + time, +}: { + dt: number + hazards: Iwt2GroundHazardState[] + party: Iwt2PartyEntityState[] + time: number +}): { + hazards: Iwt2GroundHazardState[] + party: Iwt2PartyEntityState[] + events: Iwt2ArenaEvent[] +} { + const events: Iwt2ArenaEvent[] = [] + let nextParty = party + const nextHazards: Iwt2GroundHazardState[] = [] + + for (const hazard of hazards) { + const remainingSeconds = hazard.remainingSeconds - dt + if (remainingSeconds <= 0) continue + + let nextDamageAt = hazard.nextDamageAt + if (time >= hazard.nextDamageAt) { + const result = applyPartyDamageInShape( + nextParty, + { + kind: 'circle', + position: hazard.position, + radius: hazard.radius, + }, + { + damage: hazard.damage, + sourceId: hazard.sourceId, + time, + }, + ) + nextParty = result.party + events.push(...result.events) + nextDamageAt = time + FIRE_PUDDLE_TICK_SECONDS + } + + nextHazards.push({ + ...hazard, + nextDamageAt, + remainingSeconds, + }) + } + + return { + events, + hazards: nextHazards, + party: nextParty, + } +} + +export function createHazardIndicators(hazards: Iwt2GroundHazardState[]): Iwt2ArenaIndicator[] { + return hazards.map((hazard) => createCircleIndicator({ + color: '#ff7a2f', + id: `${hazard.id}:indicator`, + mechanicId: 'fire-puddle', + phase: hazard.remainingSeconds <= hazard.fadeSeconds ? 'recover' : 'active', + position: hazard.position, + radius: hazard.radius, + sourceId: hazard.sourceId, + })) +} + +function capFirePuddles(hazards: Iwt2GroundHazardState[]): Iwt2GroundHazardState[] { + let activePuddles = 0 + let oldestActivePuddleIndex = -1 + for (let index = 0; index < hazards.length; index += 1) { + const hazard = hazards[index] + if (hazard.hazardKind !== 'firePuddle' || hazard.remainingSeconds <= hazard.fadeSeconds) continue + activePuddles += 1 + if (oldestActivePuddleIndex < 0) oldestActivePuddleIndex = index + } + if (activePuddles <= MAX_FIRE_PUDDLES || oldestActivePuddleIndex < 0) return hazards + + return hazards.map((hazard, index) => index === oldestActivePuddleIndex + ? { ...hazard, remainingSeconds: Math.min(hazard.remainingSeconds, hazard.fadeSeconds) } + : hazard) +} diff --git a/src/modes/iwt2/sim/healing.ts b/src/modes/iwt2/sim/healing.ts new file mode 100644 index 0000000..cb31dc2 --- /dev/null +++ b/src/modes/iwt2/sim/healing.ts @@ -0,0 +1,124 @@ +import type { Iwt2HealerAbility } from '../content/healerAbilities' +import type { + Iwt2ArenaEvent, + Iwt2ArenaState, + Iwt2EntityId, + Iwt2PartyEntityState, +} from './types' +import { createArenaEvent } from './mechanics' + +const HEALER_ID: Iwt2EntityId = 'player-healer' +const MAX_EVENTS = 80 +const DEFAULT_HOT_SECONDS = 8 +const DEFAULT_HOT_TICK_SECONDS = 1 + +export type Iwt2HealerCastResult = { + cast: boolean + state: TState +} + +export function castIwt2HealerAbility( + state: TState, + ability: Iwt2HealerAbility, + selectedTargetId: Iwt2EntityId, +): Iwt2HealerCastResult { + if (state.boss.health <= 0) return { cast: false, state } + const healer = state.party.find((member) => member.id === HEALER_ID) + if (!healer || healer.health <= 0 || healer.mana < ability.manaCost) return { cast: false, state } + + const targetIds = targetIdsForAbility(state.party, ability, selectedTargetId) + if (targetIds.length === 0) return { cast: false, state } + + const events: Iwt2ArenaEvent[] = [] + let eventId = state.nextEventId + const targetIdSet = new Set(targetIds) + const nextParty = state.party.map((member) => { + const mana = member.id === HEALER_ID ? Math.max(0, member.mana - ability.manaCost) : member.mana + if (!targetIdSet.has(member.id)) return { ...member, mana } + + const nextMember = applyAbilityToMember(member, ability) + const healed = Math.max(0, nextMember.health - member.health) + if (healed > 0) { + events.push(createArenaEvent(eventId, state.time, 'partyHealed', HEALER_ID, member.id, healed)) + eventId += 1 + } + return { ...nextMember, mana } + }) + + return { + cast: true, + state: { + ...state, + party: nextParty, + nextEventId: eventId, + events: [...state.events, ...events].slice(-MAX_EVENTS), + } as TState, + } +} + +function targetIdsForAbility( + party: Iwt2PartyEntityState[], + ability: Iwt2HealerAbility, + selectedTargetId: Iwt2EntityId, +): Iwt2EntityId[] { + const living = party.filter((member) => member.health > 0) + if (living.length === 0) return [] + if (ability.kind === 'group') { + return [...living] + .sort((a, b) => healthRatio(a) - healthRatio(b)) + .slice(0, 4) + .map((member) => member.id) + } + const selected = living.find((member) => member.id === selectedTargetId) + return [selected?.id ?? living[0].id] +} + +function applyAbilityToMember(member: Iwt2PartyEntityState, ability: Iwt2HealerAbility): Iwt2PartyEntityState { + if (ability.kind === 'hot') { + return { + ...healMember(member, ability.power), + hotEffects: [ + ...member.hotEffects.filter((effect) => effect.id !== ability.id), + { + id: ability.id, + label: ability.name, + power: Math.max(1, Math.round(ability.power * 0.5)), + remainingSeconds: DEFAULT_HOT_SECONDS, + tickIntervalSeconds: DEFAULT_HOT_TICK_SECONDS, + nextTickInSeconds: DEFAULT_HOT_TICK_SECONDS, + }, + ], + } + } + + if (ability.kind === 'shield') { + return { + ...member, + shield: Math.max(member.shield, ability.power), + } + } + + if (ability.kind === 'cleanse') { + return { + ...healMember(member, ability.power), + status: { + ...member.status, + knockedDownSeconds: 0, + stunnedSeconds: 0, + }, + } + } + + return healMember(member, ability.power) +} + +function healMember(member: Iwt2PartyEntityState, amount: number): Iwt2PartyEntityState { + return { + ...member, + health: Math.min(member.maxHealth, member.health + amount), + } +} + +function healthRatio(member: Iwt2PartyEntityState) { + return member.health / member.maxHealth +} diff --git a/src/modes/iwt2/sim/index.ts b/src/modes/iwt2/sim/index.ts new file mode 100644 index 0000000..742ade5 --- /dev/null +++ b/src/modes/iwt2/sim/index.ts @@ -0,0 +1,67 @@ +export { + createInitialIwt2ArenaState, + tickIwt2Arena, +} from './arena' + +export { + circlesOverlap, + circleIntersectsSegment, + distanceToSegment, + separateCircles, +} from './collision' + +export { + applyPartyDamageInShape, + createArenaEvent, + createCircleIndicator, + createConeIndicator, + createDonutIndicator, + createLaneIndicator, + createLinearMovementPlan, + indicatorPhaseFromAttack, + isIndicatorActive, + partyMemberIntersectsShape, +} from './mechanics' + +export { + castIwt2HealerAbility, +} from './healing' + +export { + addVec2, + clamp, + clampVec2ToArena, + distanceVec2, + dotVec2, + lengthSqVec2, + lengthVec2, + moveToward, + normalizeVec2, + scaleVec2, + subtractVec2, +} from './vector' + +export type { + Iwt2ArenaBounds, + Iwt2ArenaEvent, + Iwt2ArenaEventType, + Iwt2ArenaIndicator, + Iwt2ArenaIndicatorPhase, + Iwt2ArenaInput, + Iwt2ArenaState, + Iwt2BossAttackPhase, + Iwt2BossEntityState, + Iwt2Circle, + Iwt2EntityId, + Iwt2EntityKind, + Iwt2GroundHazardState, + Iwt2HotEffectState, + Iwt2HostileAddAttackPhase, + Iwt2HostileAddState, + Iwt2PartyAiRole, + Iwt2PartyEntityId, + Iwt2PartyEntityState, + Iwt2ProjectileEntityState, + Iwt2StatusState, + Iwt2Vec2, +} from './types' diff --git a/src/modes/iwt2/sim/mechanics.ts b/src/modes/iwt2/sim/mechanics.ts new file mode 100644 index 0000000..3c4a67e --- /dev/null +++ b/src/modes/iwt2/sim/mechanics.ts @@ -0,0 +1,308 @@ +import type { + Iwt2ArenaBounds, + Iwt2ArenaCircleIndicator, + Iwt2ArenaConeIndicator, + Iwt2ArenaDonutIndicator, + Iwt2ArenaEvent, + Iwt2ArenaEventType, + Iwt2ArenaIndicator, + Iwt2ArenaIndicatorPhase, + Iwt2ArenaLaneIndicator, + Iwt2EntityId, + Iwt2PartyEntityState, + Iwt2Vec2, +} from './types' +import { circleIntersectsSegment } from './collision' +import { + addVec2, + clampVec2ToArena, + distanceVec2, + normalizeVec2, + scaleVec2, + subtractVec2, +} from './vector' + +export type Iwt2LinearMovementPlan = { + direction: Iwt2Vec2 + end: Iwt2Vec2 +} + +export type Iwt2PartyDamageEffect = { + sourceId: Iwt2EntityId + time: number + damage: number + damageEventType?: Iwt2ArenaEventType + stunSeconds?: number + knockdownSeconds?: number + statusEventType?: Iwt2ArenaEventType + excludedEntityIds?: Iwt2EntityId[] +} + +export type Iwt2LaneHitShape = { + kind: 'lane' + start: Iwt2Vec2 + end: Iwt2Vec2 + width: number +} + +export type Iwt2CircleHitShape = { + kind: 'circle' + position: Iwt2Vec2 + radius: number +} + +export type Iwt2HitShape = Iwt2LaneHitShape | Iwt2CircleHitShape + +export function createArenaEvent( + id: number, + time: number, + type: Iwt2ArenaEventType, + sourceId: Iwt2EntityId, + targetId?: Iwt2EntityId, + value?: number, +): Iwt2ArenaEvent { + return { id, time, type, sourceId, targetId, value } +} + +export function createLinearMovementPlan({ + bounds, + from, + length, + radius, + target, +}: { + bounds: Iwt2ArenaBounds + from: Iwt2Vec2 + length: number + radius: number + target: Iwt2Vec2 +}): Iwt2LinearMovementPlan { + const direction = normalizeVec2(subtractVec2(target, from)) + const rawEnd = addVec2(from, scaleVec2(direction, length)) + return { + direction, + end: clampVec2ToArena(rawEnd, radius, bounds), + } +} + +export function applyPartyDamageInShape( + party: Iwt2PartyEntityState[], + shape: Iwt2HitShape, + effect: Iwt2PartyDamageEffect, +): { + party: Iwt2PartyEntityState[] + hitEntityIds: Iwt2EntityId[] + events: Iwt2ArenaEvent[] +} { + const events: Iwt2ArenaEvent[] = [] + const excluded = new Set(effect.excludedEntityIds ?? []) + const hitEntityIds: Iwt2EntityId[] = [] + const stunSeconds = effect.stunSeconds ?? 0 + const knockdownSeconds = effect.knockdownSeconds ?? stunSeconds + const nextParty = party.map((member) => { + if (member.health <= 0 || excluded.has(member.id) || !partyMemberIntersectsShape(member, shape)) { + return member + } + + hitEntityIds.push(member.id) + const shieldAbsorbed = Math.min(member.shield, effect.damage) + const healthDamage = effect.damage - shieldAbsorbed + const nextShield = member.shield - shieldAbsorbed + const nextHealth = Math.max(0, member.health - healthDamage) + events.push(createArenaEvent( + 0, + effect.time, + effect.damageEventType ?? 'partyDamaged', + effect.sourceId, + member.id, + effect.damage, + )) + if (stunSeconds > 0 || knockdownSeconds > 0) { + events.push(createArenaEvent( + 0, + effect.time, + effect.statusEventType ?? 'partyStunned', + effect.sourceId, + member.id, + Math.max(stunSeconds, knockdownSeconds), + )) + } + if (member.health > 0 && nextHealth <= 0) { + events.push(createArenaEvent(0, effect.time, 'entityDefeated', effect.sourceId, member.id)) + } + + return { + ...member, + health: nextHealth, + shield: nextShield, + status: { + ...member.status, + stunnedSeconds: Math.max(member.status.stunnedSeconds, stunSeconds), + knockedDownSeconds: Math.max(member.status.knockedDownSeconds, knockdownSeconds), + }, + } + }) + + return { party: nextParty, hitEntityIds, events } +} + +export function partyMemberIntersectsShape(member: Iwt2PartyEntityState, shape: Iwt2HitShape): boolean { + if (shape.kind === 'lane') { + return circleIntersectsSegment( + { position: member.position, radius: member.radius }, + shape.start, + shape.end, + shape.width, + ) + } + return distanceVec2(member.position, shape.position) <= shape.radius + member.radius +} + +export function createLaneIndicator({ + color, + end, + id, + mechanicId, + phase, + sourceId, + start, + width, +}: { + color: string + end: Iwt2Vec2 + id: string + mechanicId: string + phase: Iwt2ArenaIndicatorPhase + sourceId: Iwt2EntityId + start: Iwt2Vec2 + width: number +}): Iwt2ArenaLaneIndicator { + return { + color, + end: { ...end }, + fillAlpha: phase === 'active' ? 0.18 : 0.11, + id, + kind: 'lane', + lineAlpha: phase === 'active' ? 0.8 : 0.45, + mechanicId, + phase, + sourceId, + start: { ...start }, + width, + } +} + +export function createCircleIndicator({ + color, + id, + mechanicId, + phase, + position, + radius, + sourceId, +}: { + color: string + id: string + mechanicId: string + phase: Iwt2ArenaIndicatorPhase + position: Iwt2Vec2 + radius: number + sourceId: Iwt2EntityId +}): Iwt2ArenaCircleIndicator { + return { + color, + fillAlpha: phase === 'active' ? 0.18 : 0.12, + id, + kind: 'circle', + lineAlpha: phase === 'active' ? 0.75 : 0.55, + mechanicId, + phase, + position: { ...position }, + radius, + sourceId, + } +} + +export function createConeIndicator({ + angleRadians, + color, + direction, + id, + mechanicId, + origin, + phase, + range, + sourceId, +}: { + angleRadians: number + color: string + direction: Iwt2Vec2 + id: string + mechanicId: string + origin: Iwt2Vec2 + phase: Iwt2ArenaIndicatorPhase + range: number + sourceId: Iwt2EntityId +}): Iwt2ArenaConeIndicator { + return { + angleRadians, + color, + direction: normalizeVec2(direction), + fillAlpha: phase === 'active' ? 0.18 : 0.1, + id, + kind: 'cone', + lineAlpha: phase === 'active' ? 0.72 : 0.48, + mechanicId, + origin: { ...origin }, + phase, + range, + sourceId, + } +} + +export function createDonutIndicator({ + color, + id, + innerRadius, + mechanicId, + outerRadius, + phase, + position, + sourceId, +}: { + color: string + id: string + innerRadius: number + mechanicId: string + outerRadius: number + phase: Iwt2ArenaIndicatorPhase + position: Iwt2Vec2 + sourceId: Iwt2EntityId +}): Iwt2ArenaDonutIndicator { + return { + color, + fillAlpha: phase === 'active' ? 0.14 : 0.08, + id, + innerRadius, + kind: 'donut', + lineAlpha: phase === 'active' ? 0.76 : 0.5, + mechanicId, + outerRadius, + phase, + position: { ...position }, + sourceId, + } +} + +export function indicatorPhaseFromAttack( + windup: boolean, + active: boolean, +): Iwt2ArenaIndicatorPhase { + if (active) return 'active' + if (windup) return 'windup' + return 'recover' +} + +export function isIndicatorActive(indicator: Iwt2ArenaIndicator): boolean { + return indicator.phase === 'active' +} diff --git a/src/modes/iwt2/sim/partyAi.ts b/src/modes/iwt2/sim/partyAi.ts new file mode 100644 index 0000000..db3a0b6 --- /dev/null +++ b/src/modes/iwt2/sim/partyAi.ts @@ -0,0 +1,230 @@ +import { IWT2_CLASS_METADATA } from '../content/classes' +import { BULLDROME_BOSS_METADATA } from '../content/bosses' +import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types' +import { + addVec2, + clampVec2ToArena, + distanceVec2, + dotVec2, + lengthSqVec2, + moveToward, + normalizeVec2, + scaleVec2, + subtractVec2, + withFallbackFacing, +} from './vector' + +export function tickPartyMember( + member: Iwt2PartyEntityState, + state: Iwt2ArenaState, + inputMove: Iwt2Vec2, + dt: number, +): Iwt2PartyEntityState { + const metadata = IWT2_CLASS_METADATA[member.classId] + const status = { + stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt), + knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt), + invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt), + } + const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt) + const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt) + if (member.health <= 0) { + return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status } + } + if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) { + return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status } + } + + if (member.aiRole === 'player') { + const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove + const velocity = scaleVec2(normalizedInput, metadata.moveSpeed) + const position = clampVec2ToArena( + { + x: member.position.x + velocity.x * dt, + y: member.position.y + velocity.y * dt, + }, + member.radius, + state.bounds, + ) + return { + ...member, + position, + velocity, + facing: withFallbackFacing(velocity, member.facing), + attackCooldownRemaining, + castSecondsRemaining: 0, + attackReady: false, + status, + } + } + + const dangerDestination = getDangerAvoidancePosition(member, state) + const attackTarget = getPriorityAttackTarget(member, state) + const canCast = member.aiRole === 'ranged' + && attackCooldownRemaining <= 0 + && !!attackTarget + && canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position) + if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) { + const nextCastSecondsRemaining = member.castSecondsRemaining > 0 + ? castSecondsRemaining + : metadata.castTime + return { + ...member, + velocity: { x: 0, y: 0 }, + facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, member.position), member.facing), + attackCooldownRemaining, + castSecondsRemaining: nextCastSecondsRemaining, + attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0, + status, + } + } + + const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt) + const desired = dangerDestination ?? getPartyDesiredPosition(member, state, decisionSecondsRemaining) + const maxDistance = metadata.moveSpeed * dt + const position = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds) + const velocity = scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0) + return { + ...member, + position, + velocity, + facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? state.boss.position, position), member.facing), + attackCooldownRemaining, + castSecondsRemaining: 0, + attackReady: false, + status, + decisionSecondsRemaining: decisionSecondsRemaining <= 0 + ? nextDecisionInterval(member) + : decisionSecondsRemaining, + } +} + +export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2): boolean { + if (member.health <= 0) return false + if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false + const metadata = IWT2_CLASS_METADATA[member.classId] + if (metadata.attackDamage <= 0) return false + return distanceVec2(member.position, targetPosition) <= metadata.attackRange + member.radius +} + +function getPartyDesiredPosition( + member: Iwt2PartyEntityState, + state: Iwt2ArenaState, + decisionSecondsRemaining: number, +): Iwt2Vec2 { + const drift = decisionSecondsRemaining <= 0 ? decisionDrift(member, state.time) : { x: 0, y: 0 } + const attackTarget = getPriorityAttackTarget(member, state) + const anchor = attackTarget?.position ?? state.boss.position + return { + x: anchor.x + member.preferredOffset.x + drift.x, + y: anchor.y + member.preferredOffset.y + drift.y, + } +} + +function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null { + const boss = state.boss + const hazardEscape = getHazardAvoidancePosition(member, state) + if (hazardEscape) { + return hazardEscape + } + + if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) { + const distance = distanceVec2(member.position, boss.position) + const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34 + if (distance < dangerRadius) { + const away = normalizeVec2(subtractVec2(member.position, boss.position)) + return clampVec2ToArena(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member.radius, state.bounds) + } + } + + if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) { + const danger = chargeDanger(member.position, state) + if (danger.inside || danger.ahead) { + const perpendicular = { x: -danger.direction.y, y: danger.direction.x } + const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1 + const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius))) + return clampVec2ToArena(escape, member.radius, state.bounds) + } + } + + return null +} + +function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null { + let escape = { x: 0, y: 0 } + let maxNeededDistance = 0 + + for (const hazard of state.hazards) { + const offset = subtractVec2(member.position, hazard.position) + const distance = distanceVec2(member.position, hazard.position) + const dangerRadius = hazard.radius + member.radius + 46 + if (distance >= dangerRadius) continue + + const fallback = withFallbackFacing(subtractVec2(member.position, state.boss.position), { + x: member.position.x < state.bounds.width * 0.5 ? -1 : 1, + y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35, + }) + const away = distance > 0.001 ? normalizeVec2(offset) : fallback + const urgency = dangerRadius - distance + escape = addVec2(escape, scaleVec2(away, urgency)) + maxNeededDistance = Math.max(maxNeededDistance, urgency) + } + + if (maxNeededDistance <= 0) return null + + const direction = withFallbackFacing(escape, { + x: member.position.x < state.bounds.width * 0.5 ? -1 : 1, + y: 0, + }) + return clampVec2ToArena( + addVec2(member.position, scaleVec2(direction, maxNeededDistance + 72)), + member.radius, + state.bounds, + ) +} + +function getPriorityAttackTarget( + member: Iwt2PartyEntityState, + state: Iwt2ArenaState, +): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined { + if (member.health <= 0) return undefined + const livingAdds = state.hostileAdds.filter((add) => add.health > 0) + if (livingAdds.length > 0) { + return livingAdds.reduce((best, add) => ( + distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best + ), livingAdds[0]) + } + return state.boss.health > 0 ? state.boss : undefined +} + +function chargeDanger(position: Iwt2Vec2, state: Iwt2ArenaState) { + const boss = state.boss + const segment = subtractVec2(boss.chargeEnd, boss.chargeStart) + const lengthSq = Math.max(1, lengthSqVec2(segment)) + const direction = normalizeVec2(segment) + const offset = subtractVec2(position, boss.chargeStart) + const along = dotVec2(offset, segment) / lengthSq + const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along)))) + const distance = distanceVec2(position, closest) + const width = boss.radius + 22 + return { + direction, + inside: along >= -0.08 && along <= 1.08 && distance <= width, + ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38, + } +} + +function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 { + const seed = member.id.length * 17 + return { + x: Math.sin(time * 0.7 + seed) * 16, + y: Math.cos(time * 0.9 + seed) * 14, + } +} + +function nextDecisionInterval(member: Iwt2PartyEntityState): number { + if (member.aiRole === 'tank') return 0.34 + if (member.aiRole === 'ranged') return 0.48 + if (member.aiRole === 'flanker') return 0.28 + return 0.38 +} diff --git a/src/modes/iwt2/sim/types.ts b/src/modes/iwt2/sim/types.ts new file mode 100644 index 0000000..d384010 --- /dev/null +++ b/src/modes/iwt2/sim/types.ts @@ -0,0 +1,254 @@ +import type { Iwt2BossId } from '../content/bosses' +import type { Iwt2PlayerClassId } from '../content/classes' + +export type Iwt2Vec2 = { + x: number + y: number +} + +export type Iwt2ArenaBounds = { + width: number + height: number +} + +export type Iwt2PartyEntityId = + | 'player-healer' + | 'paladin-tank' + | 'ranger' + | 'mage' + | 'rogue' + | 'warrior' + +export type Iwt2BossEntityId = Iwt2BossId + +export type Iwt2EntityId = Iwt2PartyEntityId | Iwt2BossEntityId | string + +export type Iwt2EntityKind = 'party' | 'boss' | 'hostileAdd' + +export type Iwt2PartyAiRole = 'player' | 'tank' | 'ranged' | 'flanker' | 'melee' + +export type Iwt2StatusState = { + stunnedSeconds: number + knockedDownSeconds: number + invulnerableSeconds: number +} + +export type Iwt2HotEffectState = { + id: string + label: string + power: number + remainingSeconds: number + tickIntervalSeconds: number + nextTickInSeconds: number +} + +export type Iwt2PartyEntityState = { + id: Iwt2PartyEntityId + kind: 'party' + classId: Iwt2PlayerClassId + aiRole: Iwt2PartyAiRole + position: Iwt2Vec2 + velocity: Iwt2Vec2 + facing: Iwt2Vec2 + radius: number + health: number + maxHealth: number + shield: number + mana: number + maxMana: number + damageDone: number + attackCooldownRemaining: number + castSecondsRemaining: number + attackReady: boolean + status: Iwt2StatusState + hotEffects: Iwt2HotEffectState[] + preferredOffset: Iwt2Vec2 + decisionSecondsRemaining: number +} + +export type Iwt2ProjectileEntityState = { + id: string + sourceId: Iwt2EntityId + owner: 'party' | 'boss' + classId?: Iwt2PlayerClassId + projectileKind: 'arrow' | 'fireball' | 'magic' + color: string + position: Iwt2Vec2 + velocity: Iwt2Vec2 + radius: number + damage: number + remainingSeconds: number + bouncesRemaining?: number + bounceCooldownSeconds?: number +} + +export type Iwt2BossAttackPhase = + | 'idle' + | 'chargeWindup' + | 'charging' + | 'chargeRecover' + | 'slamWindup' + | 'slamRecover' + | 'relocating' + | 'fireballWindup' + | 'fireballRecover' + | 'birdSummonWindup' + | 'birdSummonRecover' + +export type Iwt2HostileAddAttackPhase = + | 'idle' + | 'flightWindup' + | 'flying' + | 'flightRecover' + +export type Iwt2BossEntityState = { + id: Iwt2BossEntityId + kind: 'boss' + bossId: Iwt2BossId + position: Iwt2Vec2 + velocity: Iwt2Vec2 + facing: Iwt2Vec2 + radius: number + health: number + maxHealth: number + meleeCooldownRemaining: number + chargeCooldownRemaining: number + chargeCount: number + attackPhase: Iwt2BossAttackPhase + phaseSecondsRemaining: number + chargeStart: Iwt2Vec2 + chargeEnd: Iwt2Vec2 + chargeHitEntityIds: Iwt2EntityId[] + slamApplied: boolean + wallContactSeconds: number + relocateTarget: Iwt2Vec2 + fireballCooldownRemaining: number + fireballTarget: Iwt2Vec2 + birdWaveThresholdsTriggered: number[] +} + +export type Iwt2HostileAddState = { + id: string + kind: 'hostileAdd' + addKind: 'yian-bird' + sourceBossId: Iwt2BossId + position: Iwt2Vec2 + velocity: Iwt2Vec2 + facing: Iwt2Vec2 + radius: number + health: number + maxHealth: number + damageDone: number + attackCooldownRemaining: number + flightCooldownRemaining: number + attackPhase: Iwt2HostileAddAttackPhase + phaseSecondsRemaining: number + flightStart: Iwt2Vec2 + flightEnd: Iwt2Vec2 + flightHitEntityIds: Iwt2EntityId[] +} + +export type Iwt2GroundHazardState = { + id: string + kind: 'groundHazard' + hazardKind: 'firePuddle' + sourceId: Iwt2EntityId + position: Iwt2Vec2 + radius: number + damage: number + remainingSeconds: number + fadeSeconds: number + nextDamageAt: number +} + +export type Iwt2ArenaEventType = + | 'bossDamaged' + | 'partyHealed' + | 'partyDamaged' + | 'partyStunned' + | 'bossChargeStart' + | 'bossChargeHit' + | 'bossSlam' + | 'entityDefeated' + +export type Iwt2ArenaEvent = { + id: number + time: number + type: Iwt2ArenaEventType + sourceId: Iwt2EntityId + targetId?: Iwt2EntityId + value?: number +} + +export type Iwt2ArenaIndicatorPhase = 'windup' | 'active' | 'recover' + +export type Iwt2ArenaIndicatorBase = { + id: string + mechanicId: string + sourceId: Iwt2EntityId + phase: Iwt2ArenaIndicatorPhase + color: string + fillAlpha?: number + lineAlpha?: number +} + +export type Iwt2ArenaLaneIndicator = Iwt2ArenaIndicatorBase & { + kind: 'lane' + start: Iwt2Vec2 + end: Iwt2Vec2 + width: number +} + +export type Iwt2ArenaCircleIndicator = Iwt2ArenaIndicatorBase & { + kind: 'circle' + position: Iwt2Vec2 + radius: number +} + +export type Iwt2ArenaConeIndicator = Iwt2ArenaIndicatorBase & { + kind: 'cone' + origin: Iwt2Vec2 + direction: Iwt2Vec2 + range: number + angleRadians: number +} + +export type Iwt2ArenaDonutIndicator = Iwt2ArenaIndicatorBase & { + kind: 'donut' + position: Iwt2Vec2 + innerRadius: number + outerRadius: number +} + +export type Iwt2ArenaIndicator = + | Iwt2ArenaLaneIndicator + | Iwt2ArenaCircleIndicator + | Iwt2ArenaConeIndicator + | Iwt2ArenaDonutIndicator + +export type Iwt2ArenaState = { + schemaVersion: 1 + time: number + bounds: Iwt2ArenaBounds + party: Iwt2PartyEntityState[] + projectiles: Iwt2ProjectileEntityState[] + hostileAdds: Iwt2HostileAddState[] + hazards: Iwt2GroundHazardState[] + boss: Iwt2BossEntityState + indicators: Iwt2ArenaIndicator[] + nextEventId: number + nextProjectileId: number + nextAddId: number + nextHazardId: number + events: Iwt2ArenaEvent[] +} + +export type Iwt2ArenaInput = { + moveX: number + moveY: number +} + +export type Iwt2Circle = { + position: Iwt2Vec2 + radius: number +} diff --git a/src/modes/iwt2/sim/vector.ts b/src/modes/iwt2/sim/vector.ts new file mode 100644 index 0000000..d0e2146 --- /dev/null +++ b/src/modes/iwt2/sim/vector.ts @@ -0,0 +1,60 @@ +import type { Iwt2ArenaBounds, Iwt2Vec2 } from './types' + +export const IWT2_ZERO_VECTOR: Iwt2Vec2 = { x: 0, y: 0 } + +export function addVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 { + return { x: a.x + b.x, y: a.y + b.y } +} + +export function subtractVec2(a: Iwt2Vec2, b: Iwt2Vec2): Iwt2Vec2 { + return { x: a.x - b.x, y: a.y - b.y } +} + +export function scaleVec2(vector: Iwt2Vec2, scale: number): Iwt2Vec2 { + return { x: vector.x * scale, y: vector.y * scale } +} + +export function dotVec2(a: Iwt2Vec2, b: Iwt2Vec2): number { + return a.x * b.x + a.y * b.y +} + +export function lengthSqVec2(vector: Iwt2Vec2): number { + return dotVec2(vector, vector) +} + +export function lengthVec2(vector: Iwt2Vec2): number { + return Math.sqrt(lengthSqVec2(vector)) +} + +export function distanceVec2(a: Iwt2Vec2, b: Iwt2Vec2): number { + return lengthVec2(subtractVec2(a, b)) +} + +export function normalizeVec2(vector: Iwt2Vec2): Iwt2Vec2 { + const length = lengthVec2(vector) + if (length <= 0.0001) return { ...IWT2_ZERO_VECTOR } + return { x: vector.x / length, y: vector.y / length } +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function clampVec2ToArena(position: Iwt2Vec2, radius: number, bounds: Iwt2ArenaBounds): Iwt2Vec2 { + return { + x: clamp(position.x, radius, bounds.width - radius), + y: clamp(position.y, radius, bounds.height - radius), + } +} + +export function moveToward(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 { + const offset = subtractVec2(target, current) + const distance = lengthVec2(offset) + if (distance <= maxDistance || distance <= 0.0001) return { ...target } + return addVec2(current, scaleVec2(offset, maxDistance / distance)) +} + +export function withFallbackFacing(nextFacing: Iwt2Vec2, fallback: Iwt2Vec2): Iwt2Vec2 { + if (lengthSqVec2(nextFacing) <= 0.0001) return { ...fallback } + return normalizeVec2(nextFacing) +} diff --git a/src/modes/iwt2/sim/yianKutKuAi.ts b/src/modes/iwt2/sim/yianKutKuAi.ts new file mode 100644 index 0000000..5b97167 --- /dev/null +++ b/src/modes/iwt2/sim/yianKutKuAi.ts @@ -0,0 +1,707 @@ +import { YIAN_KUT_KU_BOSS_METADATA, type Iwt2BossId } from '../content/bosses' +import type { + Iwt2ArenaEvent, + Iwt2ArenaIndicator, + Iwt2ArenaState, + Iwt2BossEntityState, + Iwt2GroundHazardState, + Iwt2HostileAddState, + Iwt2PartyEntityState, + Iwt2ProjectileEntityState, + Iwt2Vec2, +} from './types' +import { + applyPartyDamageInShape, + createCircleIndicator, + createLaneIndicator, + indicatorPhaseFromAttack, + partyMemberIntersectsShape, +} from './mechanics' +import { addFirePuddle } from './hazards' +import { + clamp, + clampVec2ToArena, + distanceVec2, + moveToward, + normalizeVec2, + scaleVec2, + subtractVec2, + withFallbackFacing, +} from './vector' + +export type Iwt2YianTickResult = { + boss: Iwt2BossEntityState + party: Iwt2PartyEntityState[] + hostileAdds: Iwt2HostileAddState[] + hazards: Iwt2GroundHazardState[] + projectiles: Iwt2ProjectileEntityState[] + events: Iwt2ArenaEvent[] + indicators: Iwt2ArenaIndicator[] + nextAddId: number + nextHazardId: number + nextProjectileId: number +} + +const BIRD_COUNT = 3 +const BIRD_MELEE_RANGE = 24 +const BIRD_MELEE_COOLDOWN = 1.15 +const BIRD_FLIGHT_DAMAGE = 10 +const YIAN_FIREBALL_BOUNCES = 8 +const YIAN_SAFE_WALL_MARGIN = 118 +const YIAN_CENTER_CAST_DISTANCE = 36 +const YIAN_CENTER_CHARGE_SPEED = 430 + +export function tickYianKutKu(state: Iwt2ArenaState, dt: number): Iwt2YianTickResult { + const events: Iwt2ArenaEvent[] = [] + const projectiles: Iwt2ProjectileEntityState[] = [] + let party = state.party + let hazards = state.hazards + let nextAddId = state.nextAddId + let nextHazardId = state.nextHazardId + let nextProjectileId = state.nextProjectileId + let boss = { + ...state.boss, + meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt), + fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt), + phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt), + velocity: { x: 0, y: 0 }, + } + + const birdResult = tickYianBirds({ + dt, + hazards, + nextHazardId, + party, + state, + time: state.time + dt, + }) + party = birdResult.party + hazards = birdResult.hazards + nextHazardId = birdResult.nextHazardId + events.push(...birdResult.events) + let hostileAdds = birdResult.hostileAdds + + const target = getBossTarget(party) + if (boss.health <= 0 || !target) { + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + if (boss.attackPhase === 'relocating') { + const nextPosition = clampVec2ToArena( + moveToward(boss.position, boss.relocateTarget, YIAN_CENTER_CHARGE_SPEED * dt), + boss.radius, + state.bounds, + ) + boss = { + ...boss, + position: nextPosition, + velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), + } + if (distanceVec2(nextPosition, boss.relocateTarget) <= 4 || boss.phaseSecondsRemaining <= 0) { + boss = { + ...boss, + attackPhase: 'idle', + phaseSecondsRemaining: 0, + velocity: { x: 0, y: 0 }, + } + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + if (boss.attackPhase === 'fireballWindup') { + boss = { + ...boss, + facing: withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing), + } + if (boss.phaseSecondsRemaining <= 0) { + const direction = withFallbackFacing(subtractVec2(boss.fireballTarget, boss.position), boss.facing) + projectiles.push({ + id: `boss-projectile-${nextProjectileId}`, + sourceId: boss.id, + owner: 'boss', + projectileKind: 'fireball', + color: '#ff8b2b', + position: { + x: boss.position.x + direction.x * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2), + y: boss.position.y + direction.y * (boss.radius + YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 2), + }, + velocity: scaleVec2(direction, YIAN_KUT_KU_BOSS_METADATA.fireballSpeed!), + radius: YIAN_KUT_KU_BOSS_METADATA.fireballRadius!, + damage: YIAN_KUT_KU_BOSS_METADATA.fireballDamage!, + remainingSeconds: 7, + bouncesRemaining: YIAN_FIREBALL_BOUNCES, + }) + nextProjectileId += 1 + boss = { + ...boss, + attackPhase: 'fireballRecover', + phaseSecondsRemaining: 0.35, + } + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + if (boss.attackPhase === 'fireballRecover' || boss.attackPhase === 'birdSummonRecover') { + if (boss.phaseSecondsRemaining <= 0) { + boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 } + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + if (shouldYianRelocate(boss, state)) { + const center = yianCenterTarget(state) + boss = { + ...boss, + attackPhase: 'relocating', + phaseSecondsRemaining: 1.4, + relocateTarget: center, + chargeStart: { ...boss.position }, + chargeEnd: center, + velocity: { x: 0, y: 0 }, + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + const nextThreshold = nextBirdWaveThreshold(boss) + if (nextThreshold !== undefined && boss.health / boss.maxHealth <= nextThreshold) { + const spawn = spawnBirdWave(state, nextAddId, boss.id, nextThreshold) + hostileAdds = [...hostileAdds, ...spawn.hostileAdds] + nextAddId = spawn.nextAddId + boss = { + ...boss, + attackPhase: 'birdSummonRecover', + birdWaveThresholdsTriggered: [...boss.birdWaveThresholdsTriggered, nextThreshold], + phaseSecondsRemaining: 0.55, + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + if (boss.fireballCooldownRemaining <= 0) { + if (!isYianAtCenter(boss, state)) { + const center = yianCenterTarget(state) + boss = { + ...boss, + attackPhase: 'relocating', + phaseSecondsRemaining: 1.4, + relocateTarget: center, + chargeStart: { ...boss.position }, + chargeEnd: center, + velocity: { x: 0, y: 0 }, + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + boss = { + ...boss, + attackPhase: 'fireballWindup', + fireballCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballCooldown!, + fireballTarget: { ...target.position }, + phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.fireballWindup!, + velocity: { x: 0, y: 0 }, + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt) + party = meleeResult.party + boss = meleeResult.boss + events.push(...meleeResult.events) + if (meleeResult.events.length > 0) { + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) + } + + const nextPosition = clampVec2ToArena( + moveToward(boss.position, yianApproachTarget(target, state), YIAN_KUT_KU_BOSS_METADATA.moveSpeed * dt), + boss.radius, + state.bounds, + ) + boss = { + ...boss, + position: nextPosition, + velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0), + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing), + } + return withYianIndicators({ + boss, + events, + hazards, + hostileAdds, + nextAddId, + nextHazardId, + nextProjectileId, + party, + projectiles, + }) +} + +function shouldYianRelocate( + boss: Iwt2BossEntityState, + state: Iwt2ArenaState, +): boolean { + return ( + boss.position.x < YIAN_SAFE_WALL_MARGIN + || boss.position.x > state.bounds.width - YIAN_SAFE_WALL_MARGIN + || boss.position.y < YIAN_SAFE_WALL_MARGIN + || boss.position.y > state.bounds.height - YIAN_SAFE_WALL_MARGIN + ) +} + +function isYianAtCenter(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean { + return distanceVec2(boss.position, yianCenterTarget(state)) <= YIAN_CENTER_CAST_DISTANCE +} + +function yianApproachTarget(target: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 { + return clampVec2ToArena({ + x: target.position.x + (target.position.x < state.bounds.width * 0.5 ? 190 : -190), + y: target.position.y + (target.position.y < state.bounds.height * 0.5 ? 96 : -96), + }, YIAN_KUT_KU_BOSS_METADATA.radius, state.bounds) +} + +function yianCenterTarget(state: Iwt2ArenaState): Iwt2Vec2 { + return { + x: state.bounds.width * 0.5, + y: state.bounds.height * 0.5, + } +} + +function tickYianBirds({ + dt, + hazards, + nextHazardId, + party, + state, + time, +}: { + dt: number + hazards: Iwt2GroundHazardState[] + nextHazardId: number + party: Iwt2PartyEntityState[] + state: Iwt2ArenaState + time: number +}): { + hostileAdds: Iwt2HostileAddState[] + party: Iwt2PartyEntityState[] + hazards: Iwt2GroundHazardState[] + nextHazardId: number + events: Iwt2ArenaEvent[] +} { + const events: Iwt2ArenaEvent[] = [] + const hostileAdds: Iwt2HostileAddState[] = [] + let nextParty = party + let nextHazards = hazards + let nextHazardIdValue = nextHazardId + + for (const add of state.hostileAdds) { + if (add.health <= 0) continue + let nextAdd = { + ...add, + attackCooldownRemaining: Math.max(0, add.attackCooldownRemaining - dt), + flightCooldownRemaining: Math.max(0, add.flightCooldownRemaining - dt), + phaseSecondsRemaining: Math.max(0, add.phaseSecondsRemaining - dt), + } + + if (nextAdd.attackPhase === 'flightWindup') { + if (nextAdd.phaseSecondsRemaining <= 0) { + nextAdd = { + ...nextAdd, + attackPhase: 'flying', + position: { ...nextAdd.flightStart }, + velocity: scaleVec2(normalizeVec2(subtractVec2(nextAdd.flightEnd, nextAdd.flightStart)), YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed!), + } + } + hostileAdds.push(nextAdd) + continue + } + + if (nextAdd.attackPhase === 'flying') { + const previousPosition = nextAdd.position + const nextPosition = moveToward( + nextAdd.position, + nextAdd.flightEnd, + YIAN_KUT_KU_BOSS_METADATA.birdFlightSpeed! * dt, + ) + const shape = { + kind: 'lane' as const, + start: previousPosition, + end: nextPosition, + width: nextAdd.radius, + } + const hitMembers = nextParty.filter((member) => ( + !nextAdd.flightHitEntityIds.includes(member.id) + && partyMemberIntersectsShape(member, shape) + )) + const hitResult = applyPartyDamageInShape(nextParty, shape, { + damage: BIRD_FLIGHT_DAMAGE, + excludedEntityIds: nextAdd.flightHitEntityIds, + knockdownSeconds: 0, + sourceId: nextAdd.id, + stunSeconds: YIAN_KUT_KU_BOSS_METADATA.birdStunSeconds!, + time, + }) + nextParty = hitResult.party + events.push(...hitResult.events) + for (const member of hitMembers) { + const puddle = addFirePuddle({ + damage: YIAN_KUT_KU_BOSS_METADATA.firePuddleDamage!, + duration: YIAN_KUT_KU_BOSS_METADATA.firePuddleSeconds!, + hazards: nextHazards, + nextHazardId: nextHazardIdValue, + position: member.position, + radius: YIAN_KUT_KU_BOSS_METADATA.firePuddleRadius!, + sourceId: nextAdd.id, + time, + }) + nextHazards = puddle.hazards + nextHazardIdValue = puddle.nextHazardId + } + + nextAdd = { + ...nextAdd, + flightHitEntityIds: [...nextAdd.flightHitEntityIds, ...hitResult.hitEntityIds], + position: nextPosition, + velocity: scaleVec2(subtractVec2(nextPosition, previousPosition), dt > 0 ? 1 / dt : 0), + facing: { x: 1, y: 0 }, + } + if (distanceVec2(nextPosition, nextAdd.flightEnd) <= 1) { + nextAdd = { + ...nextAdd, + attackPhase: 'flightRecover', + flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!, + phaseSecondsRemaining: 0.4, + velocity: { x: 0, y: 0 }, + } + } + hostileAdds.push(nextAdd) + continue + } + + if (nextAdd.attackPhase === 'flightRecover') { + if (nextAdd.phaseSecondsRemaining <= 0) { + nextAdd = { ...nextAdd, attackPhase: 'idle', phaseSecondsRemaining: 0 } + } + hostileAdds.push(nextAdd) + continue + } + + if (nextAdd.flightCooldownRemaining <= 0) { + hostileAdds.push(startBirdFlight(nextAdd, state, time)) + continue + } + + const target = getNearestPartyMember(nextParty, nextAdd.position) + if (!target) { + hostileAdds.push({ ...nextAdd, velocity: { x: 0, y: 0 } }) + continue + } + + const nextPosition = clampVec2ToArena( + moveToward(nextAdd.position, target.position, YIAN_KUT_KU_BOSS_METADATA.moveSpeed * 1.35 * dt), + nextAdd.radius, + state.bounds, + ) + nextAdd = { + ...nextAdd, + position: nextPosition, + velocity: scaleVec2(subtractVec2(nextPosition, nextAdd.position), dt > 0 ? 1 / dt : 0), + facing: withFallbackFacing(subtractVec2(target.position, nextPosition), nextAdd.facing), + } + if ( + nextAdd.attackCooldownRemaining <= 0 + && distanceVec2(nextAdd.position, target.position) <= nextAdd.radius + target.radius + BIRD_MELEE_RANGE + ) { + const hitResult = applyPartyDamageInShape(nextParty, { + kind: 'circle', + position: nextAdd.position, + radius: nextAdd.radius + BIRD_MELEE_RANGE, + }, { + damage: YIAN_KUT_KU_BOSS_METADATA.birdContactDamage!, + sourceId: nextAdd.id, + time, + }) + nextParty = hitResult.party + events.push(...hitResult.events) + nextAdd = { ...nextAdd, attackCooldownRemaining: BIRD_MELEE_COOLDOWN } + } + hostileAdds.push(nextAdd) + } + + return { + events, + hazards: nextHazards, + hostileAdds, + nextHazardId: nextHazardIdValue, + party: nextParty, + } +} + +function spawnBirdWave( + state: Iwt2ArenaState, + nextAddId: number, + sourceBossId: Iwt2BossId, + threshold: number, +): { hostileAdds: Iwt2HostileAddState[], nextAddId: number } { + const hostileAdds: Iwt2HostileAddState[] = [] + let nextId = nextAddId + for (let index = 0; index < BIRD_COUNT; index += 1) { + const y = birdLaneY(state, index, threshold * 100) + const radius = YIAN_KUT_KU_BOSS_METADATA.birdRadius! + const flightStart = { x: -radius - 8, y } + const flightEnd = { x: state.bounds.width + radius + 8, y } + hostileAdds.push({ + id: `yian-bird-${nextId}`, + kind: 'hostileAdd', + addKind: 'yian-bird', + sourceBossId, + position: { ...flightStart }, + velocity: { x: 0, y: 0 }, + facing: { x: 1, y: 0 }, + radius, + health: YIAN_KUT_KU_BOSS_METADATA.birdHealth!, + maxHealth: YIAN_KUT_KU_BOSS_METADATA.birdHealth!, + damageDone: 0, + attackCooldownRemaining: 0, + flightCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightCooldown!, + attackPhase: 'flightWindup', + phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!, + flightStart, + flightEnd, + flightHitEntityIds: [], + }) + nextId += 1 + } + return { hostileAdds, nextAddId: nextId } +} + +function startBirdFlight(add: Iwt2HostileAddState, state: Iwt2ArenaState, time: number): Iwt2HostileAddState { + const y = birdLaneY(state, Number(add.id.replace(/\D/g, '')) % BIRD_COUNT, time) + const flightStart = { x: -add.radius - 8, y } + const flightEnd = { x: state.bounds.width + add.radius + 8, y } + return { + ...add, + attackPhase: 'flightWindup', + phaseSecondsRemaining: YIAN_KUT_KU_BOSS_METADATA.birdFlightWindup!, + flightStart, + flightEnd, + flightHitEntityIds: [], + velocity: { x: 0, y: 0 }, + } +} + +function birdLaneY(state: Iwt2ArenaState, index: number, seed: number): number { + const laneHeight = state.bounds.height / (BIRD_COUNT + 1) + const base = laneHeight * (index + 1) + const wobble = Math.sin(seed * 8.37 + index * 2.1) * 34 + return clamp(base + wobble, 58, state.bounds.height - 58) +} + +function nextBirdWaveThreshold(boss: Iwt2BossEntityState): number | undefined { + return YIAN_KUT_KU_BOSS_METADATA.birdWaveThresholds + ?.find((threshold) => !boss.birdWaveThresholdsTriggered.includes(threshold)) +} + +function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined { + const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0) + if (livingTank) return livingTank + return party.find((member) => member.health > 0) +} + +function getNearestPartyMember(party: Iwt2PartyEntityState[], position: Iwt2Vec2): Iwt2PartyEntityState | undefined { + let nearest: Iwt2PartyEntityState | undefined + let nearestDistance = Number.POSITIVE_INFINITY + for (const member of party) { + if (member.health <= 0) continue + const distance = distanceVec2(member.position, position) + if (distance < nearestDistance) { + nearest = member + nearestDistance = distance + } + } + return nearest +} + +function maybeApplyMelee( + party: Iwt2PartyEntityState[], + boss: Iwt2BossEntityState, + target: Iwt2PartyEntityState, + time: number, +): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } { + if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] } + if (distanceVec2(boss.position, target.position) > YIAN_KUT_KU_BOSS_METADATA.meleeRange + target.radius) { + return { boss, party, events: [] } + } + const result = applyPartyDamageInShape(party, { + kind: 'circle', + position: boss.position, + radius: YIAN_KUT_KU_BOSS_METADATA.meleeRange, + }, { + damage: YIAN_KUT_KU_BOSS_METADATA.meleeDamage, + sourceId: boss.id, + time, + }) + return { + boss: { ...boss, meleeCooldownRemaining: YIAN_KUT_KU_BOSS_METADATA.meleeCooldown }, + party: result.party, + events: result.events, + } +} + +function withYianIndicators(result: Omit): Iwt2YianTickResult { + return { + ...result, + indicators: createYianIndicators(result.boss, result.hostileAdds), + } +} + +function createYianIndicators( + boss: Iwt2BossEntityState, + hostileAdds: Iwt2HostileAddState[], +): Iwt2ArenaIndicator[] { + const indicators: Iwt2ArenaIndicator[] = [] + if (boss.attackPhase === 'relocating') { + indicators.push(createLaneIndicator({ + color: '#ffce5c', + end: boss.chargeEnd, + id: `${boss.id}:center-charge`, + mechanicId: 'yian-center-charge', + phase: 'active', + sourceId: boss.id, + start: boss.chargeStart, + width: boss.radius, + })) + } + if (boss.attackPhase === 'fireballWindup') { + indicators.push(createCircleIndicator({ + color: '#ff8b2b', + id: `${boss.id}:fireball-cast`, + mechanicId: 'yian-fireball-cast', + phase: 'windup', + position: boss.position, + radius: boss.radius + 18, + sourceId: boss.id, + })) + indicators.push(createLaneIndicator({ + color: '#ffb347', + end: boss.fireballTarget, + id: `${boss.id}:fireball-line`, + mechanicId: 'yian-fireball-line', + phase: 'windup', + sourceId: boss.id, + start: boss.position, + width: YIAN_KUT_KU_BOSS_METADATA.fireballRadius! + 4, + })) + } + for (const add of hostileAdds) { + if ( + add.attackPhase !== 'flightWindup' + && add.attackPhase !== 'flying' + && add.attackPhase !== 'flightRecover' + ) { + continue + } + indicators.push(createLaneIndicator({ + color: '#ffce5c', + end: add.flightEnd, + id: `${add.id}:flight-lane`, + mechanicId: 'yian-bird-flight', + phase: indicatorPhaseFromAttack( + add.attackPhase === 'flightWindup', + add.attackPhase === 'flying', + ), + sourceId: add.id, + start: add.flightStart, + width: add.radius, + })) + } + return indicators +}