I Want To Heal 2 web/server v1.0.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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>
|
||||
Generated
+2904
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user