I Want To Heal 2 web/server v1.0.0

This commit is contained in:
Warren H
2026-06-23 21:11:43 -04:00
commit 58e131d0b2
28 changed files with 9840 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+9
View File
@@ -0,0 +1,9 @@
# Project Notes
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version.
+9
View File
@@ -0,0 +1,9 @@
# Action Mode Documents
This folder tracks the Action Mode design and implementation decisions.
## Documents
- `progression.md`: Action Mode character, dungeon tiers, coins, loot, and gear upgrade rules.
- `combat-mechanics.md`: Current boss and mob mechanics, how reusable attack modules work, and admin tuning notes.
- `implementation-notes.md`: Code ownership, main files, and next-step guidelines.
+57
View File
@@ -0,0 +1,57 @@
# Action Mode Combat Mechanics
## Current Dungeons
### Bulldrome Hunting Grounds
- iLvl 1 starts with one Bullfango pack, then Bulldrome.
- Higher tiers start with Bulldrome plus two Bullfangos.
- Bulldrome can charge, attack the tank, and ground slam after configured charge counts.
- Bullfangos can charge and attack the tank.
### Yian Kut-Ku Roost
- Trash fight: three Birds.
- Boss fight: Yian Kut-Ku plus two Birds.
- Yian Kut-Ku fires three bouncing fireballs: northeast, northwest, and south.
- Fireballs bounce off walls and players.
- Fireballs leave fire on wall/player impact.
- Fireballs persist until roughly one second before the next volley.
- Birds fly to the left side, dive across the arena, then return to the tank.
## Reusable Attack Modules
Action Mode mobs and bosses use configurable attack modules:
- `tankMelee`
- `charge`
- `groundSlam`
- `fireballVolley`
- `birdDive`
- `bodyContact`
Each module can define:
- enabled/disabled state
- frequency
- damage
- windup
- recovery
- speed
- radius
- special counters such as every N charges
The simulation reads from the mechanic registry at run start and during combat. Disabling an attack removes that behavior from new runs.
## Admin Tuning
The Action Mode Settings screen includes a mechanic admin panel. It can:
- show each mob and boss
- edit attack frequency and damage
- edit attack timing values
- remove attacks
- add removed attacks back
- reset defaults
Changes persist in local storage and apply to new Action Mode runs.
@@ -0,0 +1,25 @@
# Action Mode Implementation Notes
## Main Files
- `src/actionMode.ts`: Action Mode character save, tier data, coins, rewards, gear stats, and upgrade rules.
- `src/actionBoss/actionEncounterConfig.ts`: reusable mob/boss attack module defaults and local admin persistence.
- `src/actionBoss/bulldromeSimulation.ts`: Action Mode combat simulation and enemy state machines.
- `src/actionBoss/BulldromeScene.ts`: Phaser rendering/input adapter.
- `src/components/BulldromeBossSlice.tsx`: browser fight shell, party frames, boss bar, spell bar, and result handling.
- `src/components/ActionModeScreen.tsx`: Action Mode menus, dungeons, customization, and mechanics admin UI.
- `src/App.css`: Action Mode layout and visual styling.
## Architecture Rules
- Keep combat rules in simulation files, not Phaser scene rendering code.
- Keep text-heavy UI in React/DOM.
- Add new boss mechanics as reusable attack modules before wiring them to a specific boss.
- New dungeon tiers should be added to `ACTION_DIFFICULTY_TIERS`.
- New boss coin families should extend the Action Mode coin wallet instead of reusing Normal Mode currencies.
## Current Follow-Ups
- Add Rathian dungeon mechanics.
- Move Action Mode mechanic config from local storage into SQL tables when the backend persistence pass starts.
- Add admin CRUD for new attacks, not just enable/disable existing modules.
+44
View File
@@ -0,0 +1,44 @@
# Action Mode Progression
## Mode Separation
Action Mode has its own character, inventory, coins, experience, and dungeon progress. Normal Mode data remains separate.
## Dungeon Item-Level Tiers
Action Mode dungeons use four item-level tiers:
| Tier | Coin Color | Boss Health | Boss Damage | Loot Multiplier |
| --- | --- | ---: | ---: | ---: |
| iLvl 1 | White | 1.00x | 1.00x | 1x |
| iLvl 10 | Green | 1.55x | 1.28x | 2x |
| iLvl 20 | Blue | 2.25x | 1.62x | 3x |
| iLvl 30 | Purple | 3.10x | 2.05x | 4x |
The selected tier controls:
- enemy health scaling
- enemy attack damage scaling
- coin color dropped
- item level of dropped gear
- XP reward
## Coins
Coins are tracked per boss family and per tier color.
- Bulldrome: White/Green/Blue/Purple Bulldrome Coins
- Yian Kut-Ku: White/Green/Blue/Purple Yian Kut-Ku Coins
Legacy white coin totals are migrated into the new wallet.
## Gear
Boss gear drops at the selected dungeon tier:
- iLvl 1 tier drops iLvl 1 gear
- iLvl 10 tier drops iLvl 10 gear
- iLvl 20 tier drops iLvl 20 gear
- iLvl 30 tier drops iLvl 30 gear
Gear can upgrade within its tier band. Current rule: a piece can upgrade up to four item levels above its base tier using coins from that tier color.
+49
View File
@@ -0,0 +1,49 @@
# Action Mode
Standalone browser-only Action Mode project.
## Run
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
## TrueNAS Custom App
Use `docker-compose.truenas.yml` for the new app deployment. Copy this repo into:
```text
/mnt/usbssds/apps/iwanttoheal2/app
```
The compose file runs the web app on port `4173` with a Postgres database stored under:
```text
/mnt/usbssds/apps/iwanttoheal2/db
```
It initializes tables for auth users, cloud save slots, mechanic configs, PvP queue, and PvP matches.
Set a real `POSTGRES_PASSWORD` in the TrueNAS app environment before first launch. The compose file includes the production origins:
```text
https://iwanttoheal.phenomrom.com
https://auth.phenomrom.com
```
Server save/PvP endpoints require authenticated users. Header-based auth stays disabled until `TRUST_AUTH_HEADERS=1` is set and the reverse proxy/auth service is configured to strip incoming `x-auth-*` headers and inject trusted values from `auth.phenomrom.com`.
Step-by-step local Git, Gitea release, and TrueNAS update commands live in [docs/truenas-deploy.md](docs/truenas-deploy.md).
## Notes
- This project does not import or depend on the Normal Mode game.
- Browser UI still needs the next wiring pass to sync local saves/mechanic edits through the server API.
- Design/architecture notes live in `Action Mode Documents/`.
+38
View File
@@ -0,0 +1,38 @@
services:
iwanttoheal:
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
depends_on:
iwanttoheal-db:
condition: service_healthy
environment:
AUTH_ISSUER: https://auth.phenomrom.com
COOKIE_SECURE: '1'
CORS_ORIGINS: >-
http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com
DATABASE_URL: postgres://iwanttoheal:${POSTGRES_PASSWORD:-change-me-iwanttoheal2}@iwanttoheal-db:5432/iwanttoheal
HOST: 0.0.0.0
PORT: '4173'
TRUST_PROXY: '1'
image: node:24-bookworm-slim
ports:
- '4173:4173'
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal2/app:/app
- /mnt/usbssds/apps/iwanttoheal2/data:/app/data
working_dir: /app
iwanttoheal-db:
environment:
POSTGRES_DB: iwanttoheal
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-iwanttoheal2}
POSTGRES_USER: iwanttoheal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U iwanttoheal -d iwanttoheal"]
interval: 10s
timeout: 5s
retries: 10
image: postgres:17-bookworm
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal2/db:/var/lib/postgresql/data
+125
View File
@@ -0,0 +1,125 @@
# I Want To Heal 2 Deployment
## Current Limits
This repo currently deploys the web/server app. It does not yet contain an Android/Capacitor project, so the old APK build step cannot run here until mobile scaffolding is added.
Do not hard-code Gitea tokens in repo files. Export `GITEA_TOKEN` in your shell.
## One-Time Local Git Setup
```bash
cd /Users/warren/Documents/action-mode
git init
git branch -M main
git remote add origin https://git.whoagland.com/phenom/i-want-to-heal-2.git
```
If the remote already exists:
```bash
cd /Users/warren/Documents/action-mode
git remote set-url origin https://git.whoagland.com/phenom/i-want-to-heal-2.git
```
## Step 1: Web/Server Build Check
```bash
set -e
cd /Users/warren/Documents/action-mode
npm ci
npm run lint
npm run build
```
## Step 2: Commit And Push Code
```bash
set -e
cd /Users/warren/Documents/action-mode
VERSION="1.0.0"
git add .
git commit -m "I Want To Heal 2 web/server v$VERSION"
git push origin main
```
## Step 3: Create Gitea Release
```bash
set -e
cd /Users/warren/Documents/action-mode
export GITEA_URL="https://git.whoagland.com"
export GITEA_OWNER="phenom"
export GITEA_REPO="i-want-to-heal-2"
# export GITEA_TOKEN="paste-current-token-in-shell-only"
VERSION="1.0.0"
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v$VERSION\",\"target_commitish\":\"main\",\"name\":\"v$VERSION\",\"body\":\"I Want To Heal 2 web/server build v$VERSION\",\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(python3 -c 'import sys,json; data=json.load(sys.stdin); print(data.get("id") or data)' <<< "$RELEASE_JSON")
echo "Release ID: $RELEASE_ID"
```
## Step 4: First TrueNAS Install
In the TrueNAS shell:
```bash
set -e
mkdir -p /mnt/usbssds/apps/iwanttoheal2
cd /mnt/usbssds/apps/iwanttoheal2
git clone https://git.whoagland.com/phenom/i-want-to-heal-2.git app
mkdir -p data db
```
Create the TrueNAS custom app from `docker-compose.truenas.yml`, or paste that compose file into the custom app UI.
Set these app environment values:
```text
POSTGRES_PASSWORD=<strong unique password>
```
Only set this after the reverse proxy/auth layer strips spoofed inbound auth headers and injects trusted user headers:
```text
TRUST_AUTH_HEADERS=1
```
## Step 5: Update TrueNAS
In the TrueNAS shell:
```bash
set -e
cd /mnt/usbssds/apps/iwanttoheal2/app
git pull origin main
```
Then restart the TrueNAS custom app.
## Admin Changes
There is no separate admin server yet. Mechanic admin is currently inside the web app under Action Mode settings. This command serves the same app on port `4174` for local admin work:
After server-side admin is split out, keep this local command shape:
```bash
cd /Users/warren/Documents/action-mode
DATABASE_URL="postgres://iwanttoheal:<password>@127.0.0.1:5432/iwanttoheal" PORT=4174 npm run admin:start
```
+25
View File
@@ -0,0 +1,25 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Action Mode</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2904
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "action-mode",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"admin:start": "PORT=4174 node server/index.mjs",
"db:init": "node server/db-init.mjs",
"dev": "vite",
"build": "tsc -b --noEmit && vite build",
"lint": "eslint .",
"preview": "vite preview",
"start": "node server/index.mjs"
},
"dependencies": {
"pg": "^8.22.0",
"phaser": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
+79
View File
@@ -0,0 +1,79 @@
import { createPool, waitForDatabase } from './db.mjs'
const pool = createPool()
try {
await waitForDatabase(pool)
await pool.query('begin')
await pool.query(`
create extension if not exists pgcrypto;
create table if not exists app_users (
id uuid primary key default gen_random_uuid(),
auth_issuer text not null,
auth_subject text not null,
display_name text not null default 'Healer',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (auth_issuer, auth_subject)
);
create table if not exists save_slots (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references app_users(id) on delete cascade,
slot_key text not null default 'default',
save_json jsonb not null,
save_version integer not null default 1,
client_updated_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id, slot_key)
);
create table if not exists action_mechanic_configs (
id uuid primary key default gen_random_uuid(),
user_id uuid references app_users(id) on delete cascade,
config_key text not null default 'default',
config_json jsonb not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id, config_key)
);
create table if not exists pvp_queue (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references app_users(id) on delete cascade,
mode text not null default 'action-healer',
rating integer not null default 1000,
status text not null default 'queued',
queued_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id, mode, status)
);
create table if not exists pvp_matches (
id uuid primary key default gen_random_uuid(),
mode text not null default 'action-healer',
status text not null default 'pending',
player_one_id uuid not null references app_users(id) on delete cascade,
player_two_id uuid references app_users(id) on delete cascade,
match_state jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz
);
create index if not exists save_slots_user_id_idx on save_slots(user_id);
create index if not exists pvp_queue_mode_status_idx on pvp_queue(mode, status, queued_at);
create index if not exists pvp_matches_player_one_idx on pvp_matches(player_one_id);
create index if not exists pvp_matches_player_two_idx on pvp_matches(player_two_id);
`)
await pool.query('commit')
console.log('Database initialized.')
} catch (error) {
await pool.query('rollback').catch(() => {})
console.error(error)
process.exitCode = 1
} finally {
await pool.end()
}
+32
View File
@@ -0,0 +1,32 @@
import pg from 'pg'
const { Pool } = pg
export function getDatabaseUrl() {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL is required for server database access.')
}
return databaseUrl
}
export function createPool() {
return new Pool({
connectionString: getDatabaseUrl(),
max: Number(process.env.DB_POOL_MAX ?? 10),
})
}
export async function waitForDatabase(pool, attempts = 30) {
let lastError
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await pool.query('select 1')
return
} catch (error) {
lastError = error
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, attempt * 500)))
}
}
throw lastError
}
+219
View File
@@ -0,0 +1,219 @@
import { createReadStream, existsSync, statSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { extname, join, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createPool, waitForDatabase } from './db.mjs'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const rootDir = normalize(join(__dirname, '..'))
const distDir = join(rootDir, 'dist')
const host = process.env.HOST ?? '0.0.0.0'
const port = Number(process.env.PORT ?? 4173)
const pool = createPool()
const corsOrigins = new Set(
(process.env.CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
)
const mimeTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
}
await waitForDatabase(pool)
const server = createServer(async (request, response) => {
try {
applyCors(request, response)
if (request.method === 'OPTIONS') {
response.writeHead(204)
response.end()
return
}
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
if (url.pathname.startsWith('/api/')) {
await handleApi(request, response, url)
return
}
await serveStatic(response, url.pathname)
} catch (error) {
console.error(error)
sendJson(response, 500, { error: 'internal_error' })
}
})
server.listen(port, host, () => {
console.log(`Action Mode server listening on ${host}:${port}`)
})
function applyCors(request, response) {
const origin = request.headers.origin
if (origin && corsOrigins.has(origin)) {
response.setHeader('Access-Control-Allow-Origin', origin)
response.setHeader('Vary', 'Origin')
}
response.setHeader('Access-Control-Allow-Credentials', 'true')
response.setHeader('Access-Control-Allow-Headers', 'authorization,content-type,x-auth-issuer,x-auth-subject,x-display-name')
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
}
async function handleApi(request, response, url) {
if (request.method === 'GET' && url.pathname === '/api/health') {
const db = await pool.query('select now() as now')
sendJson(response, 200, { ok: true, database: 'ok', now: db.rows[0].now })
return
}
if (request.method === 'GET' && url.pathname === '/api/me') {
const user = await requireUser(request, response)
if (!user) return
sendJson(response, 200, { user })
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/save/')) {
const user = await requireUser(request, response)
if (!user) return
const slotKey = decodeURIComponent(url.pathname.replace('/api/save/', '')) || 'default'
const save = await pool.query(
'select slot_key, save_json, save_version, client_updated_at, updated_at from save_slots where user_id = $1 and slot_key = $2',
[user.id, slotKey],
)
sendJson(response, 200, { save: save.rows[0] ?? null })
return
}
if (request.method === 'PUT' && url.pathname.startsWith('/api/save/')) {
const user = await requireUser(request, response)
if (!user) return
const slotKey = decodeURIComponent(url.pathname.replace('/api/save/', '')) || 'default'
const body = await readJson(request)
if (!body || typeof body.save !== 'object') {
sendJson(response, 400, { error: 'invalid_save_payload' })
return
}
const saved = await pool.query(
`
insert into save_slots (user_id, slot_key, save_json, save_version, client_updated_at, updated_at)
values ($1, $2, $3, $4, $5, now())
on conflict (user_id, slot_key)
do update set
save_json = excluded.save_json,
save_version = excluded.save_version,
client_updated_at = excluded.client_updated_at,
updated_at = now()
returning slot_key, save_json, save_version, client_updated_at, updated_at
`,
[user.id, slotKey, body.save, Number(body.saveVersion ?? 1), body.clientUpdatedAt ?? null],
)
sendJson(response, 200, { save: saved.rows[0] })
return
}
if (request.method === 'POST' && url.pathname === '/api/pvp/queue') {
const user = await requireUser(request, response)
if (!user) return
const body = await readJson(request)
const mode = String(body?.mode ?? 'action-healer')
const rating = Number(body?.rating ?? 1000)
const queued = await pool.query(
`
insert into pvp_queue (user_id, mode, rating, status, updated_at)
values ($1, $2, $3, 'queued', now())
on conflict (user_id, mode, status)
do update set rating = excluded.rating, updated_at = now()
returning id, mode, rating, status, queued_at, updated_at
`,
[user.id, mode, rating],
)
sendJson(response, 200, { queue: queued.rows[0] })
return
}
if (request.method === 'DELETE' && url.pathname === '/api/pvp/queue') {
const user = await requireUser(request, response)
if (!user) return
const mode = url.searchParams.get('mode') ?? 'action-healer'
await pool.query('delete from pvp_queue where user_id = $1 and mode = $2 and status = $3', [user.id, mode, 'queued'])
sendJson(response, 200, { ok: true })
return
}
sendJson(response, 404, { error: 'not_found' })
}
async function requireUser(request, response) {
if (process.env.TRUST_AUTH_HEADERS !== '1') {
sendJson(response, 401, { error: 'auth_not_configured' })
return null
}
const authSubject = request.headers['x-auth-subject']
if (!authSubject || Array.isArray(authSubject)) {
sendJson(response, 401, { error: 'missing_auth_subject' })
return null
}
const authIssuerHeader = request.headers['x-auth-issuer']
const displayNameHeader = request.headers['x-display-name']
const authIssuer = Array.isArray(authIssuerHeader)
? authIssuerHeader[0]
: authIssuerHeader || process.env.AUTH_ISSUER || 'https://auth.phenomrom.com'
const displayName = Array.isArray(displayNameHeader)
? displayNameHeader[0]
: displayNameHeader || 'Healer'
const result = await pool.query(
`
insert into app_users (auth_issuer, auth_subject, display_name, updated_at)
values ($1, $2, $3, now())
on conflict (auth_issuer, auth_subject)
do update set display_name = excluded.display_name, updated_at = now()
returning id, auth_issuer, auth_subject, display_name, created_at, updated_at
`,
[authIssuer, authSubject, displayName],
)
return result.rows[0]
}
async function readJson(request) {
const chunks = []
for await (const chunk of request) chunks.push(chunk)
if (chunks.length === 0) return null
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
function sendJson(response, status, payload) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify(payload))
}
async function serveStatic(response, pathname) {
const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '')
const candidatePath = join(distDir, safePath === '/' ? 'index.html' : safePath)
const filePath = candidatePath.startsWith(distDir) && existsSync(candidatePath) && statSync(candidatePath).isFile()
? candidatePath
: join(distDir, 'index.html')
const contentType = mimeTypes[extname(filePath)] ?? 'application/octet-stream'
response.writeHead(200, { 'Content-Type': contentType })
if (filePath.endsWith('index.html')) {
response.end(await readFile(filePath))
return
}
createReadStream(filePath).pipe(response)
}
+530
View File
@@ -0,0 +1,530 @@
import Phaser from 'phaser'
import type { ActionDifficulty } from '../actionMode'
import {
type ActionDungeonId,
type ActionRunMode,
createBulldromeState,
getAllEnemies,
getTargetableUnits,
updateBulldromeState,
type BossInput,
type BulldromeState,
type EnemyState,
type HealTextEvent,
type NoticeTextEvent,
type TargetableState,
} from './bulldromeSimulation'
type SceneCallbacks = {
difficulty: ActionDifficulty
dungeonId: ActionDungeonId
runMode: ActionRunMode
onStateChange: (state: BulldromeState) => void
}
type KeyMap = {
W: Phaser.Input.Keyboard.Key
A: Phaser.Input.Keyboard.Key
S: Phaser.Input.Keyboard.Key
D: Phaser.Input.Keyboard.Key
R: Phaser.Input.Keyboard.Key
ONE: Phaser.Input.Keyboard.Key
TWO: Phaser.Input.Keyboard.Key
THREE: Phaser.Input.Keyboard.Key
FOUR: Phaser.Input.Keyboard.Key
FIVE: Phaser.Input.Keyboard.Key
}
export class BulldromeScene extends Phaser.Scene {
private callbacks: SceneCallbacks
private state: BulldromeState
private keys: KeyMap | null = null
private arenaGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private hazardGraphics?: Phaser.GameObjects.Graphics
private bossGraphics?: Phaser.GameObjects.Graphics
private partyGraphics?: Phaser.GameObjects.Graphics
private playerGraphics?: Phaser.GameObjects.Graphics
private fxGraphics?: Phaser.GameObjects.Graphics
private statusText?: Phaser.GameObjects.Text
private lastResetDown = false
private lastOneDown = false
private lastTwoDown = false
private lastThreeDown = false
private lastFourDown = false
private lastFiveDown = false
private queuedTargetId: string | null = null
private queuedSpell: 1 | 2 | 3 | 4 | 5 | null = null
private hudPublishTimer = 0
private seenHealEventIds = new Set<string>()
private seenNoticeEventIds = new Set<string>()
constructor(callbacks: SceneCallbacks) {
super('BulldromeScene')
this.callbacks = callbacks
this.state = createBulldromeState(callbacks.difficulty, callbacks.dungeonId, callbacks.runMode)
}
create() {
this.keys = this.input.keyboard?.addKeys('W,A,S,D,R,ONE,TWO,THREE,FOUR,FIVE') as KeyMap
this.arenaGraphics = this.add.graphics()
this.telegraphGraphics = this.add.graphics()
this.hazardGraphics = this.add.graphics()
this.bossGraphics = this.add.graphics()
this.partyGraphics = this.add.graphics()
this.playerGraphics = this.add.graphics()
this.fxGraphics = this.add.graphics()
this.statusText = this.add.text(238, 58, '', {
color: '#f4eed8',
fontFamily: 'monospace',
fontSize: '16px',
})
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
this.selectTargetAt(pointer.worldX, pointer.worldY)
})
this.cameras.main.setRoundPixels(true)
this.callbacks.onStateChange(this.state)
}
selectTarget(targetId: string) {
this.queuedTargetId = targetId
}
castSpell(slot: 1 | 2 | 3 | 4 | 5) {
this.queuedSpell = slot
}
override update(_time: number, delta: number) {
const input = this.readInput()
this.state = updateBulldromeState(this.state, input, Math.min(delta / 1000, 0.05))
if (input.reset) {
this.seenHealEventIds.clear()
this.seenNoticeEventIds.clear()
}
this.spawnHealTexts()
this.spawnNoticeTexts()
this.renderState()
this.hudPublishTimer -= delta
if (this.hudPublishTimer <= 0 || this.state.result !== 'playing' || this.state.lastHit || this.state.noticeEvents.length > 0) {
this.callbacks.onStateChange(this.state)
this.hudPublishTimer = 80
}
}
private readInput(): BossInput {
const keys = this.keys
if (!keys) {
return {
xAxis: 0,
yAxis: 0,
reset: false,
targetDelta: 0,
targetId: this.consumeQueuedTarget(),
castSpell: this.consumeQueuedSpell(),
}
}
const resetDown = keys.R.isDown
const oneDown = keys.ONE.isDown
const twoDown = keys.TWO.isDown
const threeDown = keys.THREE.isDown
const fourDown = keys.FOUR.isDown
const fiveDown = keys.FIVE.isDown
const pressedSpell = this.consumeQueuedSpell()
?? (oneDown && !this.lastOneDown ? 1
: twoDown && !this.lastTwoDown ? 2
: threeDown && !this.lastThreeDown ? 3
: fourDown && !this.lastFourDown ? 4
: fiveDown && !this.lastFiveDown ? 5
: null)
const input = {
xAxis: Number(keys.D.isDown) - Number(keys.A.isDown),
yAxis: Number(keys.S.isDown) - Number(keys.W.isDown),
reset: resetDown && !this.lastResetDown,
targetDelta: 0 as const,
targetId: this.consumeQueuedTarget(),
castSpell: pressedSpell,
}
this.lastResetDown = resetDown
this.lastOneDown = oneDown
this.lastTwoDown = twoDown
this.lastThreeDown = threeDown
this.lastFourDown = fourDown
this.lastFiveDown = fiveDown
return input
}
private renderState() {
this.drawArena()
this.drawHazards()
this.drawTelegraph()
this.drawBoss()
this.drawParty()
this.drawPlayer()
this.drawFx()
this.statusText?.setText(this.state.message)
}
private spawnHealTexts() {
for (const event of this.state.healEvents) {
if (this.seenHealEventIds.has(event.id)) continue
this.seenHealEventIds.add(event.id)
this.spawnHealText(event)
}
}
private spawnHealText(event: HealTextEvent) {
const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId)
if (!target) return
const text = this.add.text(target.x, target.y - target.radius - 18, `+${Math.ceil(event.amount)}`, {
color: '#7dff9d',
fontFamily: 'monospace',
fontSize: '18px',
fontStyle: 'bold',
stroke: '#07110b',
strokeThickness: 4,
})
text.setOrigin(0.5)
this.tweens.add({
targets: text,
y: text.y - 30,
alpha: 0,
duration: 850,
ease: 'Cubic.easeOut',
onComplete: () => text.destroy(),
})
}
private spawnNoticeTexts() {
for (const event of this.state.noticeEvents) {
if (this.seenNoticeEventIds.has(event.id)) continue
this.seenNoticeEventIds.add(event.id)
this.spawnNoticeText(event)
}
}
private spawnNoticeText(event: NoticeTextEvent) {
const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId) ?? this.state.player
const text = this.add.text(target.x, target.y - target.radius - 24, event.text, {
color: '#ffd36d',
fontFamily: 'monospace',
fontSize: '16px',
fontStyle: 'bold',
stroke: '#120b03',
strokeThickness: 4,
})
text.setOrigin(0.5)
this.tweens.add({
targets: text,
y: text.y - 24,
alpha: 0,
duration: 950,
ease: 'Cubic.easeOut',
onComplete: () => text.destroy(),
})
}
private consumeQueuedTarget() {
const targetId = this.queuedTargetId
this.queuedTargetId = null
return targetId
}
private consumeQueuedSpell() {
const spell = this.queuedSpell
this.queuedSpell = null
return spell
}
private selectTargetAt(x: number, y: number) {
const units = [this.state.player, ...this.state.party].filter((unit) => unit.hp > 0)
const target = units
.map((unit) => ({ unit, distance: Phaser.Math.Distance.Between(x, y, unit.x, unit.y) }))
.filter(({ unit, distance }) => distance <= unit.radius + 14)
.sort((a, b) => a.distance - b.distance)[0]?.unit
if (target) this.selectTarget(target.id)
}
private drawArena() {
const graphics = this.arenaGraphics
if (!graphics) return
const { width, height, padding } = this.state.arena
graphics.clear()
graphics.fillStyle(0x11151c, 1)
graphics.fillRect(0, 0, width, height)
graphics.fillStyle(0x18202a, 1)
graphics.fillRect(padding, padding, width - padding * 2, height - padding * 2)
graphics.lineStyle(3, 0x565066, 1)
graphics.strokeRect(padding, padding, width - padding * 2, height - padding * 2)
graphics.lineStyle(1, 0x252b35, 0.65)
for (let x = padding + 48; x < width - padding; x += 48) {
graphics.lineBetween(x, padding, x, height - padding)
}
for (let y = padding + 48; y < height - padding; y += 48) {
graphics.lineBetween(padding, y, width - padding, y)
}
}
private drawTelegraph() {
const graphics = this.telegraphGraphics
if (!graphics) return
graphics.clear()
for (const enemy of getAllEnemies(this.state)) {
if (enemy.hp <= 0) continue
const telegraph = enemy.telegraph
const slam = enemy.slamTelegraph
if (slam.active) {
graphics.fillStyle(0xdc5162, 0.18)
graphics.fillCircle(enemy.x, enemy.y, slam.radius)
graphics.lineStyle(4, 0xff8b8b, 0.9)
graphics.strokeCircle(enemy.x, enemy.y, slam.radius)
}
if (!telegraph.active) continue
graphics.lineStyle(telegraph.width, 0xdc5162, 0.22)
graphics.lineBetween(telegraph.start.x, telegraph.start.y, telegraph.end.x, telegraph.end.y)
graphics.lineStyle(3, 0xff8b8b, 0.82)
graphics.lineBetween(telegraph.start.x, telegraph.start.y, telegraph.end.x, telegraph.end.y)
}
}
private drawHazards() {
const graphics = this.hazardGraphics
if (!graphics) return
graphics.clear()
for (const spot of this.state.fireSpots) {
const alpha = Math.max(0.18, Math.min(0.48, spot.remaining / 20))
graphics.fillStyle(0xff7a1a, alpha)
graphics.fillCircle(spot.x, spot.y, spot.radius)
graphics.lineStyle(2, 0xffd36d, 0.72)
graphics.strokeCircle(spot.x, spot.y, spot.radius)
}
for (const fireball of this.state.fireballs) {
graphics.fillStyle(0xffd36d, 1)
graphics.fillCircle(fireball.x, fireball.y, fireball.radius + 4)
graphics.fillStyle(0xdc5162, 1)
graphics.fillCircle(fireball.x, fireball.y, fireball.radius)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeCircle(fireball.x, fireball.y, fireball.radius + 4)
}
}
private drawBoss() {
const graphics = this.bossGraphics
if (!graphics) return
graphics.clear()
for (const enemy of getAllEnemies(this.state)) {
if (enemy.hp <= 0) continue
this.drawEnemy(graphics, enemy)
}
}
private drawEnemy(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
if (enemy.kind === 'yian-kut-ku') {
this.drawYianKutKu(graphics, enemy)
return
}
if (enemy.kind === 'bird') {
this.drawBird(graphics, enemy)
return
}
const isCharging = enemy.phase === 'charging'
const isRecovering = enemy.phase === 'recovering'
const isSlam = enemy.phase === 'slamWindup'
const isBullfango = enemy.kind === 'bullfango'
const bodyWidth = isBullfango ? enemy.radius * 2.25 : enemy.radius * 2.15
const bodyHeight = isBullfango ? enemy.radius * 1.55 : enemy.radius * 1.65
const hornScale = isBullfango ? 0.68 : 1
graphics.fillStyle(isCharging ? 0xdc5162 : isSlam ? 0xe5b95f : isRecovering ? 0x8e68c4 : 0x7d4b38, 1)
graphics.fillEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight)
graphics.fillStyle(0x3b211b, 1)
graphics.fillTriangle(
enemy.x - 20 * hornScale,
enemy.y - 18 * hornScale,
enemy.x - 44 * hornScale,
enemy.y - 34 * hornScale,
enemy.x - 28 * hornScale,
enemy.y - 5 * hornScale,
)
graphics.fillTriangle(
enemy.x + 20 * hornScale,
enemy.y - 18 * hornScale,
enemy.x + 44 * hornScale,
enemy.y - 34 * hornScale,
enemy.x + 28 * hornScale,
enemy.y - 5 * hornScale,
)
graphics.fillStyle(0xf4eed8, 1)
graphics.fillCircle(enemy.x - 12 * hornScale, enemy.y - 9 * hornScale, isBullfango ? 3 : 4)
graphics.fillCircle(enemy.x + 12 * hornScale, enemy.y - 9 * hornScale, isBullfango ? 3 : 4)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight)
}
private drawYianKutKu(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
const isCasting = enemy.phase === 'windup'
graphics.fillStyle(isCasting ? 0xff9d3d : 0xd8772f, 1)
graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 2.05, enemy.radius * 1.85)
graphics.fillStyle(0xffd36d, 1)
graphics.fillTriangle(enemy.x, enemy.y - 8, enemy.x + 45, enemy.y - 2, enemy.x, enemy.y + 10)
graphics.fillStyle(0xb83b2f, 1)
graphics.fillTriangle(enemy.x - 18, enemy.y - 8, enemy.x - 42, enemy.y - 25, enemy.x - 30, enemy.y + 7)
graphics.fillTriangle(enemy.x + 10, enemy.y - 22, enemy.x + 22, enemy.y - 44, enemy.x + 31, enemy.y - 12)
graphics.fillStyle(0xf4eed8, 1)
graphics.fillCircle(enemy.x + 14, enemy.y - 9, 4)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 2.05, enemy.radius * 1.85)
}
private drawBird(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
const isFlying = enemy.phase === 'windup' || enemy.phase === 'charging' || enemy.phase === 'recovering'
graphics.fillStyle(isFlying ? 0xf4eed8 : 0xb8e7ff, 1)
graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25)
graphics.fillStyle(0x5d91ff, 1)
graphics.fillTriangle(enemy.x - 8, enemy.y, enemy.x - 34, enemy.y - 12, enemy.x - 22, enemy.y + 10)
graphics.fillTriangle(enemy.x + 8, enemy.y, enemy.x + 34, enemy.y - 12, enemy.x + 22, enemy.y + 10)
graphics.fillStyle(0xffd36d, 1)
graphics.fillTriangle(enemy.x + 13, enemy.y - 2, enemy.x + 28, enemy.y + 2, enemy.x + 13, enemy.y + 7)
graphics.fillStyle(0x090a0d, 1)
graphics.fillCircle(enemy.x + 7, enemy.y - 5, 3)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25)
}
private drawPlayer() {
const graphics = this.playerGraphics
if (!graphics) return
const player = this.state.player
graphics.clear()
this.drawTargetRing(graphics, player)
this.drawUnitIcon(graphics, player, player.stunTimer > 0 ? 0xe5b95f : 0x30ff7a)
if (player.shield > 0) {
graphics.lineStyle(2, 0xb8e7ff, 0.85)
graphics.strokeCircle(player.x, player.y, player.radius + 9)
}
if (player.stunTimer > 0) {
graphics.fillStyle(0xe5b95f, 1)
graphics.fillCircle(player.x - 10, player.y - 24, 3)
graphics.fillCircle(player.x + 2, player.y - 29, 3)
graphics.fillCircle(player.x + 13, player.y - 23, 3)
}
}
private drawParty() {
const graphics = this.partyGraphics
if (!graphics) return
graphics.clear()
for (const member of this.state.party) {
this.drawTargetRing(graphics, member)
this.drawUnitIcon(graphics, member, this.getUnitColor(member), member.hp > 0 ? 1 : 0.38)
if (member.renewTimer > 0) {
graphics.lineStyle(2, 0x3f9a66, 0.9)
graphics.strokeCircle(member.x, member.y, member.radius + 5)
}
if (member.shield > 0) {
graphics.lineStyle(2, 0xb8e7ff, 0.85)
graphics.strokeCircle(member.x, member.y, member.radius + 9)
}
if (member.stunTimer > 0) {
graphics.fillStyle(0xe5b95f, 1)
graphics.fillCircle(member.x - 8, member.y - 22, 3)
graphics.fillCircle(member.x + 6, member.y - 24, 3)
}
}
}
private drawTargetRing(graphics: Phaser.GameObjects.Graphics, unit: TargetableState) {
if (this.state.targetId !== unit.id) return
graphics.lineStyle(3, 0xe5b95f, 1)
graphics.strokeCircle(unit.x, unit.y, unit.radius + 9)
}
private drawUnitIcon(
graphics: Phaser.GameObjects.Graphics,
unit: TargetableState,
color: number,
alpha = 1,
) {
graphics.fillStyle(color, alpha)
graphics.fillCircle(unit.x, unit.y, unit.radius)
graphics.lineStyle(2, unit.invulnerableTimer > 0 ? 0xffffff : 0x090a0d, alpha)
graphics.strokeCircle(unit.x, unit.y, unit.radius)
if (unit.role === 'healer') {
graphics.lineStyle(3, 0x082815, alpha)
graphics.lineBetween(unit.x - 7, unit.y, unit.x + 7, unit.y)
graphics.lineBetween(unit.x, unit.y - 7, unit.x, unit.y + 7)
return
}
if (unit.role === 'tank') {
graphics.fillStyle(0x3b2d12, alpha)
graphics.fillTriangle(unit.x, unit.y - 10, unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4)
graphics.fillTriangle(unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4, unit.x, unit.y + 10)
graphics.lineStyle(2, 0xfff0a8, alpha)
graphics.strokeTriangle(unit.x, unit.y - 10, unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4)
graphics.lineBetween(unit.x - 9, unit.y - 4, unit.x, unit.y + 10)
graphics.lineBetween(unit.x + 9, unit.y - 4, unit.x, unit.y + 10)
return
}
if (unit.role === 'melee') {
graphics.lineStyle(3, 0x16090b, alpha)
graphics.lineBetween(unit.x - 7, unit.y + 7, unit.x + 7, unit.y - 7)
graphics.lineBetween(unit.x - 4, unit.y - 7, unit.x + 7, unit.y + 4)
return
}
graphics.lineStyle(3, 0x10091c, alpha)
graphics.strokeCircle(unit.x, unit.y, 7)
graphics.lineBetween(unit.x - 9, unit.y, unit.x + 9, unit.y)
}
private getUnitColor(unit: TargetableState) {
if (unit.role === 'tank') return 0xe5b95f
if (unit.id === 'melee-1') return 0xdc5162
if (unit.id === 'melee-2') return 0xf08a4b
if (unit.id === 'ranged-1') return 0x8e68c4
if (unit.id === 'ranged-2') return 0x5d91ff
return 0x30ff7a
}
private drawFx() {
const graphics = this.fxGraphics
if (!graphics) return
graphics.clear()
if (this.state.lastHit === 'boss') {
graphics.lineStyle(4, 0xb8e7ff, 0.85)
for (const enemy of getAllEnemies(this.state)) {
if (enemy.hp > 0) graphics.strokeCircle(enemy.x, enemy.y, enemy.radius + 12)
}
}
if (this.state.lastHit === 'player') {
graphics.lineStyle(5, 0xdc5162, 0.8)
graphics.strokeCircle(this.state.player.x, this.state.player.y, this.state.player.radius + 16)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
import type { EnemyKind } from './bulldromeSimulation'
export type ActionAttackKind =
| 'tankMelee'
| 'charge'
| 'groundSlam'
| 'fireballVolley'
| 'birdDive'
| 'bodyContact'
export type ActionAttackConfig = {
id: string
label: string
kind: ActionAttackKind
enabled: boolean
frequencySeconds: number
damage: number
windupSeconds?: number
recoverSeconds?: number
speed?: number
radius?: number
everyNthCharge?: number
}
export type ActionEnemyMechanicConfig = {
kind: EnemyKind
label: string
role: 'boss' | 'mob'
attacks: ActionAttackConfig[]
}
export type ActionMechanicConfig = Record<EnemyKind, ActionEnemyMechanicConfig>
export const ACTION_MECHANIC_CONFIG_KEY = 'i-want-to-heal:action-mode-mechanics:v1'
export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
bulldrome: {
kind: 'bulldrome',
label: 'Bulldrome',
role: 'boss',
attacks: [
createAttack('bulldrome-tank-melee', 'Tank Melee', 'tankMelee', 0.9, 13),
createAttack('bulldrome-charge', 'Charge', 'charge', 2.8, 24, {
windupSeconds: 0.82,
recoverSeconds: 0.86,
speed: 650,
}),
createAttack('bulldrome-ground-slam', 'Ground Slam', 'groundSlam', 3, 28, {
windupSeconds: 1.25,
radius: 142,
everyNthCharge: 3,
}),
createAttack('bulldrome-body-contact', 'Body Contact', 'bodyContact', 0, 10),
],
},
bullfango: {
kind: 'bullfango',
label: 'Bullfango',
role: 'mob',
attacks: [
createAttack('bullfango-tank-melee', 'Tank Melee', 'tankMelee', 1.35, 6),
createAttack('bullfango-charge', 'Charge', 'charge', 1.6, 13, {
windupSeconds: 0.68,
recoverSeconds: 1.05,
speed: 510,
}),
createAttack('bullfango-body-contact', 'Body Contact', 'bodyContact', 0, 4),
],
},
'yian-kut-ku': {
kind: 'yian-kut-ku',
label: 'Yian Kut-Ku',
role: 'boss',
attacks: [
createAttack('yian-tank-melee', 'Tank Peck', 'tankMelee', 1, 11),
createAttack('yian-fireballs', 'Fireball Volley', 'fireballVolley', 2.4, 16, {
windupSeconds: 1,
recoverSeconds: 1.1,
speed: 275,
}),
createAttack('yian-body-contact', 'Body Contact', 'bodyContact', 0, 8),
],
},
bird: {
kind: 'bird',
label: 'Bird',
role: 'mob',
attacks: [
createAttack('bird-tank-melee', 'Tank Claw', 'tankMelee', 1.15, 5),
createAttack('bird-dive', 'Dive Flight', 'birdDive', 3.2, 12, {
speed: 340,
}),
createAttack('bird-body-contact', 'Body Contact', 'bodyContact', 0, 4),
],
},
}
export function loadActionMechanicConfig(): ActionMechanicConfig {
if (typeof window === 'undefined') return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
const saved = window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY)
if (!saved) return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
try {
return mergeMechanicConfig(JSON.parse(saved) as Partial<ActionMechanicConfig>)
} catch {
return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
}
}
export function saveActionMechanicConfig(config: ActionMechanicConfig) {
if (typeof window === 'undefined') return
window.localStorage.setItem(ACTION_MECHANIC_CONFIG_KEY, JSON.stringify(config))
}
export function resetActionMechanicConfig() {
const config = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
saveActionMechanicConfig(config)
return config
}
export function getActionEnemyMechanic(kind: EnemyKind) {
return loadActionMechanicConfig()[kind]
}
export function getActionAttack(kind: EnemyKind, attackKind: ActionAttackKind) {
return getActionEnemyMechanic(kind).attacks.find((attack) => attack.kind === attackKind)
}
export function getEnabledActionAttack(kind: EnemyKind, attackKind: ActionAttackKind) {
const attack = getActionAttack(kind, attackKind)
return attack?.enabled ? attack : null
}
function createAttack(
id: string,
label: string,
kind: ActionAttackKind,
frequencySeconds: number,
damage: number,
options: Partial<Omit<ActionAttackConfig, 'id' | 'label' | 'kind' | 'enabled' | 'frequencySeconds' | 'damage'>> = {},
): ActionAttackConfig {
return {
id,
label,
kind,
enabled: true,
frequencySeconds,
damage,
...options,
}
}
function mergeMechanicConfig(saved: Partial<ActionMechanicConfig>) {
const merged = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
for (const kind of Object.keys(merged) as EnemyKind[]) {
const savedEnemy = saved[kind]
if (!savedEnemy) continue
const savedAttacks = Array.isArray(savedEnemy.attacks) ? savedEnemy.attacks : []
merged[kind] = {
...merged[kind],
...savedEnemy,
kind,
attacks: merged[kind].attacks.map((attack) => ({
...attack,
...savedAttacks.find((candidate) => candidate.id === attack.id),
})),
}
}
return merged
}
function cloneConfig(config: ActionMechanicConfig): ActionMechanicConfig {
return structuredClone(config)
}
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
export type ActionDifficulty = 'ilvl-1' | 'ilvl-10' | 'ilvl-20' | 'ilvl-30'
export type ActionDungeonId = 'bulldrome' | 'yian-kut-ku' | 'rathian'
export type ActionRunMode = 'hunt' | 'marathon'
export type ActionGearSource = 'bulldrome' | 'yian-kut-ku'
export type ActionCoinColor = 'white' | 'green' | 'blue' | 'purple'
export type ActionCoinWallet = Record<ActionGearSource, Record<ActionCoinColor, number>>
export type ActionDifficultyTier = {
id: ActionDifficulty
label: string
itemLevel: 1 | 10 | 20 | 30
coinColor: ActionCoinColor
coinLabel: string
healthMultiplier: number
damageMultiplier: number
lootMultiplier: number
experience: number
}
export type ActionGearSlot =
| 'weapon'
| 'helmet'
| 'chest'
| 'gloves'
| 'boots'
| 'pants'
| 'ring'
| 'necklace'
| 'trinket'
export type ActionGearPiece = {
id: string
slug: string
name: string
source: ActionGearSource
slot: ActionGearSlot
itemLevel: number
}
export type ActionGearStats = {
healingPower: number
stamina: number
}
export type ActionRunReward = {
coins: number
coinName: string
experience: number
gear: ActionGearPiece[]
leveledUp: boolean
}
export type ActionCharacter = {
id: string
name: string
level: number
experience: number
actionCoins: ActionCoinWallet
bulldromeCoins: number
yianKutKuCoins: number
bulldromeNormalClears: number
bulldromeHardClears: number
yianKutKuNormalClears: number
yianKutKuHardClears: number
inventory: ActionGearPiece[]
}
export const ACTION_GEAR_SLOTS: Array<{
slot: ActionGearSlot
label: string
glyph: string
}> = [
{ slot: 'weapon', label: 'Weapon', glyph: '/' },
{ slot: 'helmet', label: 'Helmet', glyph: 'H' },
{ slot: 'chest', label: 'Chest', glyph: 'C' },
{ slot: 'gloves', label: 'Gloves', glyph: 'G' },
{ slot: 'boots', label: 'Boots', glyph: 'B' },
{ slot: 'pants', label: 'Pants', glyph: 'P' },
{ slot: 'ring', label: 'Ring', glyph: 'O' },
{ slot: 'necklace', label: 'Necklace', glyph: 'N' },
{ slot: 'trinket', label: 'Trinket', glyph: 'T' },
]
export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [
{
id: 'ilvl-1',
label: 'iLvl 1',
itemLevel: 1,
coinColor: 'white',
coinLabel: 'White Coins',
healthMultiplier: 1,
damageMultiplier: 1,
lootMultiplier: 1,
experience: 60,
},
{
id: 'ilvl-10',
label: 'iLvl 10',
itemLevel: 10,
coinColor: 'green',
coinLabel: 'Green Coins',
healthMultiplier: 1.55,
damageMultiplier: 1.28,
lootMultiplier: 2,
experience: 110,
},
{
id: 'ilvl-20',
label: 'iLvl 20',
itemLevel: 20,
coinColor: 'blue',
coinLabel: 'Blue Coins',
healthMultiplier: 2.25,
damageMultiplier: 1.62,
lootMultiplier: 3,
experience: 180,
},
{
id: 'ilvl-30',
label: 'iLvl 30',
itemLevel: 30,
coinColor: 'purple',
coinLabel: 'Purple Coins',
healthMultiplier: 3.1,
damageMultiplier: 2.05,
lootMultiplier: 4,
experience: 280,
},
]
const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2'
const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1'
const MAX_ACTION_LEVEL = 25
export const BULLDROME_UPGRADE_COST = 5
export const ACTION_GEAR_UPGRADE_COST = 5
const DEFAULT_ACTION_CHARACTER: ActionCharacter = {
id: 'action-local-1',
name: 'Action Healer',
level: 1,
experience: 0,
actionCoins: createEmptyCoinWallet(),
bulldromeCoins: 0,
yianKutKuCoins: 0,
bulldromeNormalClears: 0,
bulldromeHardClears: 0,
yianKutKuNormalClears: 0,
yianKutKuHardClears: 0,
inventory: [],
}
export function loadActionCharacter(): ActionCharacter {
const saved = window.localStorage.getItem(ACTION_SAVE_KEY)
?? window.localStorage.getItem(LEGACY_ACTION_SAVE_KEY)
if (!saved) return DEFAULT_ACTION_CHARACTER
try {
const parsed = JSON.parse(saved) as Partial<ActionCharacter>
const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience)
return {
...DEFAULT_ACTION_CHARACTER,
...parsed,
id: DEFAULT_ACTION_CHARACTER.id,
experience,
level: getActionLevel(experience),
actionCoins: normalizeCoinWallet(parsed),
bulldromeCoins: Number(parsed.bulldromeCoins ?? DEFAULT_ACTION_CHARACTER.bulldromeCoins),
yianKutKuCoins: Number(parsed.yianKutKuCoins ?? DEFAULT_ACTION_CHARACTER.yianKutKuCoins),
bulldromeNormalClears: Number(parsed.bulldromeNormalClears ?? DEFAULT_ACTION_CHARACTER.bulldromeNormalClears),
bulldromeHardClears: Number(parsed.bulldromeHardClears ?? DEFAULT_ACTION_CHARACTER.bulldromeHardClears),
yianKutKuNormalClears: Number(parsed.yianKutKuNormalClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuNormalClears),
yianKutKuHardClears: Number(parsed.yianKutKuHardClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuHardClears),
inventory: Array.isArray(parsed.inventory) ? parsed.inventory.map(normalizeGearPiece) : [],
}
} catch {
return DEFAULT_ACTION_CHARACTER
}
}
export function saveActionCharacter(character: ActionCharacter) {
window.localStorage.setItem(ACTION_SAVE_KEY, JSON.stringify(character))
}
export function completeBulldromeHunt(
character: ActionCharacter,
difficulty: ActionDifficulty,
): { character: ActionCharacter, reward: ActionRunReward } {
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
const lootMultiplier = tier.lootMultiplier
const coinReward = coinRoll * lootMultiplier
const experienceReward = tier.experience
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
const gear = rollBulldromeGear(lootMultiplier, tier.itemLevel)
const actionCoins = addActionCoins(character.actionCoins, 'bulldrome', tier.coinColor, coinReward)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
actionCoins,
bulldromeCoins: actionCoins.bulldrome.white,
bulldromeNormalClears: character.bulldromeNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
bulldromeHardClears: character.bulldromeHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
inventory: [...character.inventory, ...gear],
},
reward: {
coins: coinReward,
experience: experienceReward,
gear,
coinName: `${tier.coinLabel.replace(' Coins', '')} Bulldrome Coins`,
leveledUp: nextLevel > character.level,
},
}
}
export function completeActionDungeonHunt(
character: ActionCharacter,
dungeonId: ActionDungeonId,
difficulty: ActionDifficulty,
): { character: ActionCharacter, reward: ActionRunReward } {
if (dungeonId === 'bulldrome') return completeBulldromeHunt(character, difficulty)
if (dungeonId === 'yian-kut-ku') {
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
const lootMultiplier = tier.lootMultiplier
const coinReward = coinRoll * lootMultiplier
const experienceReward = Math.ceil(tier.experience * 1.25)
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
const gear = rollDungeonGear('yian-kut-ku', lootMultiplier, tier.itemLevel)
const actionCoins = addActionCoins(character.actionCoins, 'yian-kut-ku', tier.coinColor, coinReward)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
actionCoins,
yianKutKuCoins: actionCoins['yian-kut-ku'].white,
yianKutKuNormalClears: character.yianKutKuNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
yianKutKuHardClears: character.yianKutKuHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
inventory: [...character.inventory, ...gear],
},
reward: {
coins: coinReward,
coinName: `${tier.coinLabel.replace(' Coins', '')} Yian Kut-Ku Coins`,
experience: experienceReward,
gear,
leveledUp: nextLevel > character.level,
},
}
}
const experienceReward = getActionDifficultyTier(difficulty).experience
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
},
reward: {
coins: 0,
coinName: 'Coins',
experience: experienceReward,
gear: [],
leveledUp: nextLevel > character.level,
},
}
}
export function upgradeBulldromeGear(character: ActionCharacter, itemId: string): ActionCharacter {
const item = character.inventory.find((candidate) => candidate.id === itemId)
if (!item || item.itemLevel >= getActionGearUpgradeCap(item) || getUpgradeCoinCount(character, item) < ACTION_GEAR_UPGRADE_COST) return character
return {
...character,
...spendUpgradeCoins(character, item),
inventory: character.inventory.map((candidate) => (
candidate.id === itemId
? { ...candidate, itemLevel: candidate.itemLevel + 1 }
: candidate
)),
}
}
export function getUpgradeCoinCount(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
const fullItem = item as Partial<Pick<ActionGearPiece, 'itemLevel'>>
const coinColor = getCoinColorForItemLevel(fullItem.itemLevel ?? 1)
return getActionCoinCount(character, item.source, coinColor)
}
export function getUpgradeCoinName(item: Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>) {
const tier = getTierForItemLevel(item.itemLevel ?? 1)
const sourceName = item.source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
return `${tier.coinLabel.replace(' Coins', '')} ${sourceName} Coins`
}
export function getActionGearStats(item: Pick<ActionGearPiece, 'slot' | 'itemLevel'>): ActionGearStats {
const slotWeight = item.slot === 'weapon'
? 2
: item.slot === 'chest' || item.slot === 'helmet' || item.slot === 'pants'
? 1.5
: 1
const healingPower = Math.ceil(item.itemLevel * slotWeight)
const stamina = item.slot === 'ring' || item.slot === 'necklace' || item.slot === 'trinket'
? item.itemLevel * 2
: item.itemLevel
return { healingPower, stamina }
}
export function getActionLevel(experience: number) {
return Math.min(MAX_ACTION_LEVEL, 1 + Math.floor(Math.max(0, experience) / 220))
}
export function getActionLevelProgress(character: ActionCharacter) {
const currentLevelStart = (character.level - 1) * 220
const nextLevelStart = character.level * 220
const earnedThisLevel = Math.max(0, character.experience - currentLevelStart)
const neededThisLevel = Math.max(1, nextLevelStart - currentLevelStart)
return {
currentLevelStart,
nextLevelStart,
percent: character.level >= MAX_ACTION_LEVEL
? 100
: Math.max(0, Math.min(100, (earnedThisLevel / neededThisLevel) * 100)),
}
}
export function getActionDifficultyTier(difficulty: ActionDifficulty) {
return ACTION_DIFFICULTY_TIERS.find((tier) => tier.id === difficulty) ?? ACTION_DIFFICULTY_TIERS[0]
}
export function getActionCoinCount(
character: Pick<ActionCharacter, 'actionCoins' | 'bulldromeCoins' | 'yianKutKuCoins'>,
source: ActionGearSource,
color: ActionCoinColor,
) {
if (color === 'white') return source === 'yian-kut-ku' ? character.yianKutKuCoins : character.bulldromeCoins
return character.actionCoins?.[source]?.[color] ?? 0
}
export function getTierForItemLevel(itemLevel: number) {
return [...ACTION_DIFFICULTY_TIERS]
.reverse()
.find((tier) => itemLevel >= tier.itemLevel) ?? ACTION_DIFFICULTY_TIERS[0]
}
export function getActionGearUpgradeCap(item: Pick<ActionGearPiece, 'itemLevel'>) {
return getTierForItemLevel(item.itemLevel).itemLevel + 4
}
function rollBulldromeGear(rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
return rollDungeonGear('bulldrome', rolls, itemLevel)
}
function rollDungeonGear(source: ActionGearSource, rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
const gear: ActionGearPiece[] = []
for (let index = 0; index < rolls; index += 1) {
if (Math.random() > 0.75) continue
const slot = ACTION_GEAR_SLOTS[randomInt(0, ACTION_GEAR_SLOTS.length - 1)]
gear.push(createDungeonGear(source, slot.slot, itemLevel))
}
return gear
}
function createDungeonGear(source: ActionGearSource, slot: ActionGearSlot, itemLevel: ActionDifficultyTier['itemLevel']): ActionGearPiece {
const slotMeta = ACTION_GEAR_SLOTS.find((candidate) => candidate.slot === slot)!
const sourceName = source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
return {
id: `${source}-${slot}-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
slug: `${source}-${slot}`,
name: `${sourceName} ${slotMeta.label}`,
source,
slot,
itemLevel,
}
}
function normalizeGearPiece(item: ActionGearPiece): ActionGearPiece {
const source = item.source ?? (item.slug?.startsWith('yian-kut-ku') ? 'yian-kut-ku' : 'bulldrome')
return {
...item,
source,
}
}
function spendUpgradeCoins(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
const itemWithLevel = item as Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>
const coinColor = getCoinColorForItemLevel(itemWithLevel.itemLevel ?? 1)
const actionCoins = addActionCoins(character.actionCoins, item.source, coinColor, -ACTION_GEAR_UPGRADE_COST)
if (item.source === 'yian-kut-ku' && coinColor === 'white') {
return { actionCoins, yianKutKuCoins: actionCoins['yian-kut-ku'].white }
}
if (item.source === 'bulldrome' && coinColor === 'white') {
return { actionCoins, bulldromeCoins: actionCoins.bulldrome.white }
}
return { actionCoins }
}
function getCoinColorForItemLevel(itemLevel: number) {
return getTierForItemLevel(itemLevel).coinColor
}
function createEmptyCoinWallet(): ActionCoinWallet {
return {
bulldrome: { white: 0, green: 0, blue: 0, purple: 0 },
'yian-kut-ku': { white: 0, green: 0, blue: 0, purple: 0 },
}
}
function normalizeCoinWallet(parsed: Partial<ActionCharacter>) {
const wallet = createEmptyCoinWallet()
const saved = parsed.actionCoins
for (const source of ['bulldrome', 'yian-kut-ku'] as const) {
for (const color of ['white', 'green', 'blue', 'purple'] as const) {
wallet[source][color] = Number(saved?.[source]?.[color] ?? 0)
}
}
wallet.bulldrome.white = Number(parsed.bulldromeCoins ?? wallet.bulldrome.white)
wallet['yian-kut-ku'].white = Number(parsed.yianKutKuCoins ?? wallet['yian-kut-ku'].white)
return wallet
}
function addActionCoins(
wallet: ActionCoinWallet,
source: ActionGearSource,
color: ActionCoinColor,
amount: number,
) {
return {
...wallet,
[source]: {
...wallet[source],
[color]: Math.max(0, wallet[source][color] + amount),
},
}
}
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
+790
View File
@@ -0,0 +1,790 @@
import { useEffect, useMemo, useState } from 'react'
import {
loadActionMechanicConfig,
resetActionMechanicConfig,
saveActionMechanicConfig,
type ActionAttackConfig,
type ActionMechanicConfig,
} from '../actionBoss/actionEncounterConfig'
import type { EnemyKind } from '../actionBoss/bulldromeSimulation'
import {
ACTION_DIFFICULTY_TIERS,
ACTION_GEAR_SLOTS,
ACTION_GEAR_UPGRADE_COST,
completeActionDungeonHunt,
getActionCoinCount,
getActionDifficultyTier,
getActionGearStats,
getActionLevelProgress,
getUpgradeCoinCount,
getUpgradeCoinName,
loadActionCharacter,
saveActionCharacter,
upgradeBulldromeGear,
type ActionCharacter,
type ActionDifficulty,
type ActionDungeonId,
type ActionGearPiece,
type ActionGearSlot,
type ActionGearSource,
type ActionRunMode,
type ActionRunReward,
} from '../actionMode'
import { BulldromeBossSlice } from './BulldromeBossSlice'
type ActionModeScreenProps = {
onBack?: () => void
}
type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings'
type ActionHubScreen = 'menu' | ActionHubTab
type PlayableActionDungeonId = Exclude<ActionDungeonId, 'rathian'>
const ACTION_HUB_ITEMS: Array<{
id: ActionHubTab
label: string
glyph: string
description: string
}> = [
{ id: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Run action dungeons and earn gear.' },
{ id: 'raids', label: 'Raids', glyph: 'R', description: 'Large action encounters.' },
{ id: 'pvp', label: 'PVP', glyph: 'P', description: 'Action healer competitions.' },
{ id: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through action fights.' },
{ id: 'customize', label: 'Customize Character', glyph: 'C', description: 'Manage Bulldrome gear and upgrades.' },
{ id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' },
]
export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
const [character, setCharacter] = useState<ActionCharacter>(() => loadActionCharacter())
const [activeDifficulty, setActiveDifficulty] = useState<ActionDifficulty | null>(null)
const [activeDungeonId, setActiveDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
const [activeRunMode, setActiveRunMode] = useState<ActionRunMode>('hunt')
const [activeScreen, setActiveScreen] = useState<ActionHubScreen>('menu')
const [lastReward, setLastReward] = useState<ActionRunReward | null>(null)
const [runKey, setRunKey] = useState(0)
const [message, setMessage] = useState('')
const progress = useMemo(() => getActionLevelProgress(character), [character])
useEffect(() => {
saveActionCharacter(character)
}, [character])
if (activeDifficulty) {
return (
<div className="action-run-shell">
<BulldromeBossSlice
dungeonId={activeDungeonId}
difficulty={activeDifficulty}
runMode={activeRunMode}
key={`${activeDungeonId}-${activeDifficulty}-${activeRunMode}-${runKey}`}
onExit={() => {
setLastReward(null)
setActiveDifficulty(null)
setActiveScreen('dungeons')
}}
onRunComplete={() => {
if (lastReward && activeRunMode === 'hunt') return
const { character: nextCharacter, reward } = completeActionDungeonHunt(character, activeDungeonId, activeDifficulty)
setCharacter(nextCharacter)
if (activeRunMode === 'hunt') setLastReward(reward)
setMessage('')
}}
/>
{lastReward && (
<RunRewardModal
dungeonId={activeDungeonId}
difficulty={activeDifficulty}
reward={lastReward}
onGoAgain={() => {
setLastReward(null)
setRunKey((current) => current + 1)
}}
onMainMenu={() => {
setLastReward(null)
setActiveDifficulty(null)
setActiveScreen('menu')
}}
/>
)}
</div>
)
}
return (
<main className="game-shell action-mode-shell">
<section className="content-screen action-mode-screen">
<div className="screen-heading action-screen-heading">
<div>
<p className="eyebrow">Action Mode</p>
<h1>{getHubTitle(activeScreen)}</h1>
</div>
<div className="action-heading-meta">
<div className="action-character-strip">
<strong>{character.name}</strong>
<small>Healer</small>
<small>Level {character.level}</small>
<div className="header-xp" title={`${character.experience} action experience`}>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
{(activeScreen !== 'menu' || onBack) && (
<button
className="back-button"
onClick={() => {
if (activeScreen === 'menu') onBack?.()
else setActiveScreen('menu')
}}
type="button"
>
Back
</button>
)}
</div>
</div>
{activeScreen === 'menu' && (
<nav className="action-hub-nav" aria-label="Action mode sections">
{ACTION_HUB_ITEMS.map((item) => (
<button
className="menu-card"
key={item.id}
onClick={() => setActiveScreen(item.id)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</nav>
)}
{message && <p className="action-mode-message">{message}</p>}
{activeScreen === 'dungeons' && (
<DungeonsPanel
character={character}
onStart={(dungeonId, difficulty) => {
setActiveRunMode('hunt')
setLastReward(null)
setActiveDungeonId(dungeonId)
setRunKey((current) => current + 1)
setActiveDifficulty(difficulty)
}}
onStartMarathon={(dungeonId, difficulty) => {
setActiveRunMode('marathon')
setLastReward(null)
setActiveDungeonId(dungeonId)
setRunKey((current) => current + 1)
setActiveDifficulty(difficulty)
}}
/>
)}
{activeScreen === 'customize' && (
<CustomizePanel
character={character}
onUpgrade={(itemId) => {
const before = character.inventory.find((item) => item.id === itemId)
const nextCharacter = upgradeBulldromeGear(character, itemId)
const coinCount = before ? getUpgradeCoinCount(character, before) : 0
setCharacter(nextCharacter)
setMessage(
before && before.itemLevel < 5 && coinCount >= ACTION_GEAR_UPGRADE_COST
? `${before.name} upgraded to item level ${before.itemLevel + 1}.`
: 'Upgrade unavailable.',
)
}}
/>
)}
{activeScreen === 'settings' && (
<ActionMechanicsAdmin />
)}
{activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && (
<section className="action-placeholder-panel">
<p className="eyebrow">{getHubTitle(activeScreen)}</p>
<h2>Coming Soon</h2>
<p>This section has its own Action Mode button now. Content hooks can land here without touching Normal Mode.</p>
</section>
)}
</section>
</main>
)
}
function ActionMechanicsAdmin() {
const [config, setConfig] = useState<ActionMechanicConfig>(() => loadActionMechanicConfig())
const [enemyPage, setEnemyPage] = useState(0)
const [attackPageByEnemy, setAttackPageByEnemy] = useState<Partial<Record<EnemyKind, number>>>({})
function updateAttack(enemyKind: EnemyKind, attackId: string, patch: Partial<ActionAttackConfig>) {
const next = {
...config,
[enemyKind]: {
...config[enemyKind],
attacks: config[enemyKind].attacks.map((attack) => (
attack.id === attackId ? { ...attack, ...patch } : attack
)),
},
}
setConfig(next)
saveActionMechanicConfig(next)
}
function resetConfig() {
setConfig(resetActionMechanicConfig())
setEnemyPage(0)
setAttackPageByEnemy({})
}
const enemyKinds = Object.keys(config) as EnemyKind[]
const enemyKind = enemyKinds[Math.min(enemyPage, enemyKinds.length - 1)] ?? enemyKinds[0]
const enemy = config[enemyKind]
const enabledAttacks = enemy.attacks.filter((attack) => attack.enabled)
const disabledAttacks = enemy.attacks.filter((attack) => !attack.enabled)
const attackPage = Math.min(attackPageByEnemy[enemyKind] ?? 0, Math.max(0, enabledAttacks.length - 1))
const activeAttack = enabledAttacks[attackPage] ?? null
function setAttackPage(enemyKind: EnemyKind, page: number) {
setAttackPageByEnemy((current) => ({ ...current, [enemyKind]: page }))
}
return (
<section className="action-mechanics-admin">
<header>
<div>
<p className="eyebrow">Admin</p>
<h2>Mob And Boss Mechanics</h2>
</div>
<button className="back-button" onClick={resetConfig} type="button">Reset Defaults</button>
</header>
<article className="action-mechanic-card" key={enemy.kind}>
<div className="action-mechanic-heading">
<div>
<p className="eyebrow">{enemy.role}</p>
<h3>{enemy.label}</h3>
</div>
<span>{enemyPage + 1} / {enemyKinds.length}</span>
</div>
<div className="action-page-controls" aria-label="Mechanic pages">
<button onClick={() => setEnemyPage((page) => Math.max(0, page - 1))} disabled={enemyPage === 0} type="button">
Prev Mob
</button>
<button
onClick={() => setEnemyPage((page) => Math.min(enemyKinds.length - 1, page + 1))}
disabled={enemyPage >= enemyKinds.length - 1}
type="button"
>
Next Mob
</button>
</div>
{activeAttack ? (
<div className="action-attack-list">
<div className="action-page-controls" aria-label="Attack pages">
<button onClick={() => setAttackPage(enemyKind, Math.max(0, attackPage - 1))} disabled={attackPage === 0} type="button">
Prev Attack
</button>
<span>{attackPage + 1} / {enabledAttacks.length}</span>
<button
onClick={() => setAttackPage(enemyKind, Math.min(enabledAttacks.length - 1, attackPage + 1))}
disabled={attackPage >= enabledAttacks.length - 1}
type="button"
>
Next Attack
</button>
</div>
<AttackEditor
attack={activeAttack}
key={activeAttack.id}
onChange={(patch) => updateAttack(enemyKind, activeAttack.id, patch)}
/>
</div>
) : (
<p className="action-empty-note">No active attacks.</p>
)}
{disabledAttacks.length > 0 && (
<div className="action-disabled-attacks">
<strong>Add Attack</strong>
{disabledAttacks.map((attack) => (
<button
key={attack.id}
onClick={() => {
updateAttack(enemyKind, attack.id, { enabled: true })
setAttackPage(enemyKind, enabledAttacks.length)
}}
type="button"
>
{attack.label}
</button>
))}
</div>
)}
</article>
</section>
)
}
function AttackEditor({
attack,
onChange,
}: {
attack: ActionAttackConfig
onChange: (patch: Partial<ActionAttackConfig>) => void
}) {
return (
<section className="action-attack-editor">
<header>
<div>
<strong>{attack.label}</strong>
<small>{attack.kind}</small>
</div>
<button onClick={() => onChange({ enabled: false })} type="button">Remove</button>
</header>
<div className="action-attack-fields">
<label>
Frequency
<input
min="0"
onChange={(event) => onChange({ frequencySeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.frequencySeconds}
/>
</label>
<label>
Damage
<input
min="0"
onChange={(event) => onChange({ damage: Number(event.target.value) })}
step="1"
type="number"
value={attack.damage}
/>
</label>
{attack.windupSeconds !== undefined && (
<label>
Windup
<input
min="0"
onChange={(event) => onChange({ windupSeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.windupSeconds}
/>
</label>
)}
{attack.recoverSeconds !== undefined && (
<label>
Recover
<input
min="0"
onChange={(event) => onChange({ recoverSeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.recoverSeconds}
/>
</label>
)}
{attack.speed !== undefined && (
<label>
Speed
<input
min="0"
onChange={(event) => onChange({ speed: Number(event.target.value) })}
step="10"
type="number"
value={attack.speed}
/>
</label>
)}
{attack.radius !== undefined && (
<label>
Radius
<input
min="0"
onChange={(event) => onChange({ radius: Number(event.target.value) })}
step="1"
type="number"
value={attack.radius}
/>
</label>
)}
{attack.everyNthCharge !== undefined && (
<label>
Every Charges
<input
min="1"
onChange={(event) => onChange({ everyNthCharge: Number(event.target.value) })}
step="1"
type="number"
value={attack.everyNthCharge}
/>
</label>
)}
</div>
</section>
)
}
function DungeonsPanel({
character,
onStart,
onStartMarathon,
}: {
character: ActionCharacter
onStart: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
onStartMarathon: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
}) {
const [selectedDungeonId, setSelectedDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
const [selectedDifficulty, setSelectedDifficulty] = useState<ActionDifficulty>('ilvl-1')
const selectedDungeon = ACTION_DUNGEONS.find((dungeon) => dungeon.id === selectedDungeonId) ?? ACTION_DUNGEONS[0]
const selectedTier = getActionDifficultyTier(selectedDifficulty)
const selectedCoinCount = getActionCoinCount(character, selectedDungeon.source, selectedTier.coinColor)
return (
<div className="action-dungeon-board">
<section className="action-dungeon-list" aria-label="Action dungeons">
{ACTION_DUNGEONS.map((dungeon) => {
const selected = dungeon.id === selectedDungeonId
const locked = dungeon.locked
return (
<button
className={`action-dungeon-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={dungeon.id}
onClick={() => {
if (!locked) setSelectedDungeonId(dungeon.id as PlayableActionDungeonId)
}}
type="button"
>
<span className="action-dungeon-glyph">{dungeon.glyph}</span>
<span>
<small>{dungeon.eyebrow}</small>
<strong>{dungeon.name}</strong>
<i>{dungeon.summary}</i>
</span>
</button>
)
})}
</section>
<aside className="action-dungeon-setup">
<section className="action-dungeon-selected">
<p className="eyebrow">Selected Run</p>
<h2>{selectedDungeon.name}</h2>
<p>{selectedDungeon.description}</p>
<div className="tag-row">
<span>{selectedTier.label}</span>
<span>{selectedTier.healthMultiplier}x HP</span>
<span>{selectedTier.damageMultiplier}x damage</span>
<span>{selectedTier.coinLabel}</span>
</div>
<dl>
<div><dt>Tier Coins</dt><dd>{selectedCoinCount}</dd></div>
<div><dt>Gear Drop</dt><dd>iLvl {selectedTier.itemLevel}</dd></div>
<div><dt>XP</dt><dd>{selectedTier.experience}</dd></div>
</dl>
</section>
<section className="action-dungeon-tier">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<div className="action-tier-grid">
{ACTION_DIFFICULTY_TIERS.map((tier) => (
<button
className={`${selectedDifficulty === tier.id ? 'selected' : ''} coin-${tier.coinColor}`}
key={tier.id}
onClick={() => setSelectedDifficulty(tier.id)}
type="button"
>
<strong>{tier.label}</strong>
<span>{tier.coinLabel}</span>
</button>
))}
</div>
</section>
<section className="action-dungeon-start">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<div className="action-dungeon-actions">
<button className="primary-button" onClick={() => onStart(selectedDungeonId, selectedDifficulty)} type="button">
Start Hunt
</button>
<button className="primary-button" onClick={() => onStartMarathon(selectedDungeonId, selectedDifficulty)} type="button">
Start Marathon
</button>
</div>
<p>Marathon respawns the boss and extra mobs after a 5 second break.</p>
</section>
</aside>
</div>
)
}
const ACTION_DUNGEONS: Array<{
id: ActionDungeonId
eyebrow: string
glyph: string
name: string
summary: string
description: string
source: ActionGearSource
coinLabel: string
locked?: boolean
}> = [
{
id: 'bulldrome',
eyebrow: 'Dungeon 1',
glyph: 'B',
name: 'Bulldrome Hunting Grounds',
summary: 'Charges, slams, and Bullfango pressure.',
description: 'Bulldrome drops White Bulldrome Coins and item level 1 Bulldrome gear.',
source: 'bulldrome',
coinLabel: 'White Bulldrome Coins',
},
{
id: 'yian-kut-ku',
eyebrow: 'Dungeon 2',
glyph: 'Y',
name: 'Yian Kut-Ku Roost',
summary: 'Bouncing fireballs and dive birds.',
description: 'Yian Kut-Ku fireballs bounce until the next cast and leave fire on walls or players.',
source: 'yian-kut-ku',
coinLabel: 'White Yian Kut-Ku Coins',
},
{
id: 'rathian',
eyebrow: 'Dungeon 3',
glyph: 'R',
name: 'Rathian Nest',
summary: 'Coming next.',
description: 'Rathian mechanics will be built after Yian Kut-Ku.',
source: 'bulldrome',
coinLabel: 'Rathian Coins',
locked: true,
},
]
function RunRewardModal({
dungeonId,
difficulty,
onGoAgain,
onMainMenu,
reward,
}: {
dungeonId: PlayableActionDungeonId
difficulty: ActionDifficulty
onGoAgain: () => void
onMainMenu: () => void
reward: ActionRunReward
}) {
return (
<div className="action-run-reward-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-run-reward-title">
<section className="action-run-reward-modal">
<p className="eyebrow">Dungeon Complete</p>
<h1 id="action-run-reward-title">
{getRunRewardTitle(dungeonId, difficulty)}
</h1>
<div className="action-run-reward-summary">
<div>
<dt>XP</dt>
<dd>{reward.experience}</dd>
</div>
<div>
<dt>{reward.coinName}</dt>
<dd>{reward.coins}</dd>
</div>
<div>
<dt>Level</dt>
<dd>{reward.leveledUp ? 'Up' : 'No Change'}</dd>
</div>
</div>
<div className="action-run-loot-list">
<strong>Loot</strong>
{reward.gear.length === 0 ? (
<p>No gear dropped.</p>
) : (
reward.gear.map((item) => (
<span key={item.id}>{item.name} · ilvl {item.itemLevel}</span>
))
)}
</div>
<div className="action-run-reward-actions">
<button className="primary-button" onClick={onGoAgain} type="button">Go Again</button>
<button className="back-button" onClick={onMainMenu} type="button">Main Menu</button>
</div>
</section>
</div>
)
}
function CustomizePanel({
character,
onUpgrade,
}: {
character: ActionCharacter
onUpgrade: (itemId: string) => void
}) {
const [selectedSlot, setSelectedSlot] = useState<ActionGearSlot>('weapon')
const slotMeta = ACTION_GEAR_SLOTS.find((slot) => slot.slot === selectedSlot) ?? ACTION_GEAR_SLOTS[0]
const filteredItems = useMemo(() => (
character.inventory
.filter((item) => item.slot === selectedSlot)
.sort((a, b) => b.itemLevel - a.itemLevel || a.name.localeCompare(b.name))
), [character.inventory, selectedSlot])
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? filteredItems[0] ?? null
return (
<section className="action-gear-panel">
<article className="action-gear-summary">
<p className="eyebrow">Craft Table</p>
<h2>Bulldrome Gear</h2>
<p>
Pick a gear slot, inspect that slot inventory, then preview the upgrade
cost and stat gain before spending coins.
</p>
</article>
<div className="action-customize-layout">
<div className="action-slot-grid" aria-label="Gear slots">
{ACTION_GEAR_SLOTS.map((slot) => {
const count = character.inventory.filter((item) => item.slot === slot.slot).length
return (
<button
className={selectedSlot === slot.slot ? 'selected' : ''}
key={slot.slot}
onClick={() => {
setSelectedSlot(slot.slot)
setSelectedItemId(null)
}}
type="button"
>
<span>{slot.glyph}</span>
<strong>{slot.label}</strong>
<small>{count} owned</small>
</button>
)
})}
</div>
<aside className="action-inventory-panel">
<header>
<div>
<p className="eyebrow">Inventory</p>
<h2>{slotMeta.label}</h2>
</div>
<span>{selectedItem ? `${getUpgradeCoinCount(character, selectedItem)} Coins` : `${character.bulldromeCoins} / ${character.yianKutKuCoins} Coins`}</span>
</header>
<div className="action-inventory-list">
{filteredItems.length === 0 ? (
<p>No {slotMeta.label} pieces yet.</p>
) : (
filteredItems.map((item) => (
<button
className={selectedItem?.id === item.id ? 'selected' : ''}
key={item.id}
onClick={() => setSelectedItemId(item.id)}
type="button"
>
<strong>{item.name}</strong>
<small>ilvl {item.itemLevel}</small>
</button>
))
)}
</div>
<GearDetail
coins={selectedItem ? getUpgradeCoinCount(character, selectedItem) : 0}
item={selectedItem}
onUpgrade={() => {
if (selectedItem) onUpgrade(selectedItem.id)
}}
/>
</aside>
</div>
</section>
)
}
function GearDetail({
coins,
item,
onUpgrade,
}: {
coins: number
item: ActionGearPiece | null
onUpgrade: () => void
}) {
if (!item) {
return (
<section className="action-gear-detail empty">
<p>Select a slot with gear to see stats and upgrade costs.</p>
</section>
)
}
const currentStats = getActionGearStats(item)
const nextItem = { ...item, itemLevel: Math.min(5, item.itemLevel + 1) }
const nextStats = getActionGearStats(nextItem)
const coinName = getUpgradeCoinName(item)
const canUpgrade = coins >= ACTION_GEAR_UPGRADE_COST && item.itemLevel < 5
return (
<section className="action-gear-detail">
<header>
<div>
<p className="eyebrow">Selected</p>
<h2>{item.name}</h2>
</div>
<span>ilvl {item.itemLevel}</span>
</header>
<dl className="action-stat-compare">
<div>
<dt>Healing</dt>
<dd>{currentStats.healingPower} {nextStats.healingPower}</dd>
</div>
<div>
<dt>Stamina</dt>
<dd>{currentStats.stamina} {nextStats.stamina}</dd>
</div>
<div>
<dt>Upgrade Cost</dt>
<dd>{item.itemLevel >= 5 ? 'Max' : `${ACTION_GEAR_UPGRADE_COST} ${coinName}`}</dd>
</div>
<div>
<dt>You Have</dt>
<dd>{coins} {coinName}</dd>
</div>
</dl>
<button disabled={!canUpgrade} onClick={onUpgrade} type="button">
{item.itemLevel >= 5 ? 'Max Level' : `Upgrade to ilvl ${item.itemLevel + 1}`}
</button>
</section>
)
}
function getHubTitle(screen: ActionHubScreen) {
if (screen === 'menu') return 'Action Mode'
return ACTION_HUB_ITEMS.find((item) => item.id === screen)?.label ?? 'Action Mode'
}
function getRunRewardTitle(dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) {
const dungeonName = dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku Hunt' : 'Bulldrome Hunt'
return `${getActionDifficultyTier(difficulty).label} ${dungeonName}`
}
+346
View File
@@ -0,0 +1,346 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Phaser from 'phaser'
import { BulldromeScene } from '../actionBoss/BulldromeScene'
import {
getActionDifficultyTier,
type ActionDifficulty,
type ActionRunMode,
} from '../actionMode'
import {
createBulldromeState,
getEncounterHp,
getEncounterTitle,
getEnemyFrames,
getRaidFrames,
SPELLS,
type BulldromeState,
type ActionDungeonId,
type EnemyFrame,
type RaidFrame,
type SpellDefinition,
type SpellSlot,
} from '../actionBoss/bulldromeSimulation'
type BulldromeBossSliceProps = {
dungeonId?: ActionDungeonId
difficulty?: ActionDifficulty
runMode?: ActionRunMode
onExit: () => void
onRunComplete?: () => void
}
function getRunTitle(dungeonId: ActionDungeonId, difficulty: ActionDifficulty, runMode: ActionRunMode) {
const suffix = runMode === 'marathon' ? 'Marathon' : 'Hunt'
const tier = getActionDifficultyTier(difficulty).label
if (dungeonId === 'yian-kut-ku') return `${tier} Yian Kut-Ku ${suffix}`
return `${tier} Bulldrome ${suffix}`
}
export function BulldromeBossSlice({
dungeonId = 'bulldrome',
difficulty = 'ilvl-1',
runMode = 'hunt',
onExit,
onRunComplete,
}: BulldromeBossSliceProps) {
const mountRef = useRef<HTMLDivElement | null>(null)
const gameRef = useRef<Phaser.Game | null>(null)
const sceneRef = useRef<BulldromeScene | null>(null)
const completionSentRef = useRef(false)
const rewardedBossKillsRef = useRef(0)
const [state, setState] = useState<BulldromeState>(() => createBulldromeState(difficulty, dungeonId, runMode))
const raidFrames = useMemo(() => getRaidFrames(state), [state])
const enemyFrames = useMemo(() => getEnemyFrames(state), [state])
const encounterHp = useMemo(() => getEncounterHp(state), [state])
const encounterTitle = useMemo(() => getEncounterTitle(state), [state])
const resultLabel = useMemo(() => {
if (state.result === 'win') return 'Hunt Complete'
if (state.result === 'loss') return 'Carted'
if (state.encounterStep === 'trash') return 'Bullfangos'
return state.boss.phase === 'slamWindup'
? 'Slam'
: state.boss.phase === 'mauling'
? 'Tank'
: state.boss.phase === 'windup'
? 'Dodge'
: state.boss.phase === 'recovering'
? 'Punish'
: 'Fight'
}, [state.boss.phase, state.encounterStep, state.result])
useEffect(() => {
if (!mountRef.current || gameRef.current) return
const scene = new BulldromeScene({ difficulty, dungeonId, runMode, onStateChange: setState })
sceneRef.current = scene
const game = new Phaser.Game({
type: Phaser.CANVAS,
parent: mountRef.current,
width: 960,
height: 540,
backgroundColor: '#11151c',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [scene],
})
gameRef.current = game
return () => {
game.destroy(true)
gameRef.current = null
sceneRef.current = null
}
}, [difficulty, dungeonId, runMode])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
const selectedIndex = Math.max(0, raidFrames.findIndex((frame) => frame.selected))
const delta = event.key === 'ArrowDown' ? 1 : -1
const nextFrame = raidFrames[(selectedIndex + delta + raidFrames.length) % raidFrames.length]
if (nextFrame) sceneRef.current?.selectTarget(nextFrame.id)
}
if (['1', '2', '3', '4', '5'].includes(event.key)) {
event.preventDefault()
sceneRef.current?.castSpell(Number(event.key) as SpellSlot)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [raidFrames])
useEffect(() => {
if (runMode === 'marathon') {
if (state.bossKills <= rewardedBossKillsRef.current) return
rewardedBossKillsRef.current = state.bossKills
onRunComplete?.()
return
}
if (state.result !== 'win' || completionSentRef.current) return
completionSentRef.current = true
onRunComplete?.()
}, [onRunComplete, runMode, state.bossKills, state.result])
return (
<main className="boss-slice-shell">
<section className="boss-slice-stage">
<div className="boss-slice-heading">
<div>
<p className="eyebrow">Action Boss Prototype</p>
<h1>{getRunTitle(dungeonId, difficulty, runMode)}</h1>
</div>
<button className="back-button" onClick={onExit} type="button">Back</button>
</div>
<div className="boss-slice-layout">
<aside className="boss-party-frames" aria-label="Party frames">
{raidFrames.map((frame) => (
<PartyFrame
frame={frame}
key={frame.id}
onSelect={() => sceneRef.current?.selectTarget(frame.id)}
/>
))}
</aside>
<div className="boss-playfield-panel">
<div className="boss-window-bossbar">
<strong>{encounterTitle}</strong>
<span>{Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}</span>
<i>
<b style={{ width: `${Math.max(0, Math.min(100, (encounterHp.hp / encounterHp.maxHp) * 100))}%` }} />
</i>
</div>
{state.player.currentCast && (
<div className="boss-castbar boss-field-castbar">
<div>
<strong>{SPELLS[state.player.currentCast.spell].name}</strong>
<span>{state.player.currentCast.remaining.toFixed(1)}s</span>
</div>
<i>
<b
style={{
width: `${Math.max(0, Math.min(100, ((state.player.currentCast.total - state.player.currentCast.remaining) / state.player.currentCast.total) * 100))}%`,
}}
/>
</i>
</div>
)}
<div className="boss-canvas-wrap" ref={mountRef} aria-label="Bulldrome boss fight canvas" />
</div>
<aside className="boss-hud">
<div className="boss-hud-status">
<p className="eyebrow">State</p>
<h2>{resultLabel}</h2>
<p>{state.message}</p>
</div>
<Meter label="Player" value={state.player.hp} max={state.player.maxHp} tone="player" />
<div className="boss-enemy-list">
{enemyFrames.map((enemy) => (
<EnemyRow enemy={enemy} key={enemy.id} />
))}
</div>
<div className="boss-spellbar">
{(Object.values(SPELLS) as SpellDefinition[]).map((spell) => (
<SpellButton
cooldown={state.player.spellCooldowns[spell.slot]}
key={spell.slot}
onCast={() => sceneRef.current?.castSpell(spell.slot)}
spell={spell}
/>
))}
</div>
<dl className="boss-stat-grid">
<div>
<dt>Boss</dt>
<dd>{state.boss.phase}</dd>
</div>
<div>
<dt>Time</dt>
<dd>{state.elapsed.toFixed(1)}s</dd>
</div>
<div>
<dt>Stun</dt>
<dd>{state.player.stunTimer > 0 ? `${state.player.stunTimer.toFixed(1)}s` : 'Clear'}</dd>
</div>
<div>
<dt>Target</dt>
<dd>{raidFrames.find((frame) => frame.selected)?.name ?? 'None'}</dd>
</div>
</dl>
<div className="boss-controls">
<strong>Controls</strong>
<span>WASD: move</span>
<span>Up / Down: target frame</span>
<span>1-5: healing spells</span>
<span>R: reset</span>
</div>
</aside>
</div>
</section>
</main>
)
}
function EnemyRow({ enemy }: { enemy: EnemyFrame }) {
const percent = Math.max(0, Math.min(100, (enemy.hp / enemy.maxHp) * 100))
return (
<div className={`boss-enemy-row ${enemy.kind}`}>
<div>
<strong>{enemy.name}</strong>
<span>{Math.ceil(enemy.hp)} / {enemy.maxHp}</span>
</div>
<i>
<b style={{ width: `${percent}%` }} />
</i>
</div>
)
}
function PartyFrame({
frame,
onSelect,
}: {
frame: RaidFrame
onSelect: () => void
}) {
const percent = Math.max(0, Math.min(100, (frame.hp / frame.maxHp) * 100))
const shieldPercent = Math.max(0, Math.min(100 - percent, (frame.shield / frame.maxHp) * 100))
return (
<button
className={`party-frame ${frame.selected ? 'selected' : ''} ${frame.hp <= 0 ? 'dead' : ''}`}
onClick={onSelect}
type="button"
>
<span className={`role-chip ${frame.role}`}>{frame.role}</span>
<strong>{frame.name}</strong>
<small>{Math.ceil(frame.hp)} / {frame.maxHp}</small>
<i>
<span className="party-health-fill" style={{ width: `${percent}%` }} />
{frame.shield > 0 && (
<span
className="party-shield-fill"
style={{
left: `${percent}%`,
width: `${shieldPercent}%`,
}}
/>
)}
</i>
{frame.shield > 0 && <em>Shield {Math.ceil(frame.shield)}</em>}
{frame.renewTimer > 0 && <em>Renew {frame.renewTimer.toFixed(0)}s</em>}
</button>
)
}
function SpellButton({
cooldown,
onCast,
spell,
}: {
cooldown: number
onCast: () => void
spell: SpellDefinition
}) {
const cooldownPercent = spell.cooldown > 0
? Math.max(0, Math.min(100, (cooldown / spell.cooldown) * 100))
: 0
return (
<button
className={cooldown > 0 ? 'cooling' : ''}
onClick={onCast}
type="button"
>
<strong>{spell.slot}</strong>
<span>{spell.name}</span>
{cooldown > 0 && (
<>
<i style={{ height: `${cooldownPercent}%` }} />
<em>{cooldown.toFixed(1)}s</em>
</>
)}
</button>
)
}
function Meter({
label,
max,
tone,
value,
}: {
label: string
max: number
tone: 'player' | 'boss'
value: number
}) {
const percent = Math.max(0, Math.min(100, (value / max) * 100))
return (
<div className={`boss-meter ${tone}`}>
<div>
<strong>{label}</strong>
<span>{Math.ceil(value)} / {max}</span>
</div>
<i>
<b style={{ width: `${percent}%` }} />
</i>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ActionModeScreen } from './components/ActionModeScreen'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ActionModeScreen />
</StrictMode>,
)
+2171
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": true,
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})