Android build v1.1.15

This commit is contained in:
Warren H
2026-07-04 12:43:59 -04:00
parent a849cd2c69
commit 99e97e5208
47 changed files with 8470 additions and 1610 deletions
+360 -1
View File
@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react'
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
import {
IWT2_BOSS_METADATA,
type Iwt2BalanceOverrides,
type Iwt2BossId,
type Iwt2BossMetadata,
} from '../modes/iwt2/content/bosses'
type AdminItem = {
id: number
@@ -130,7 +136,38 @@ type AdminData = {
classes: AdminClass[]
}
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes'
type Iwt2AdminAsset = {
filename: string
size: number
url: string
}
type Iwt2AdminData = {
balanceOverrides: Iwt2BalanceOverrides
bossAssets: Iwt2AdminAsset[]
}
type Iwt2AdminAttack = {
name: string
frequency: string
damage: string
details: string
}
type Iwt2AdminEntityRow = {
id: string
bossId: Iwt2BossId
kind: 'boss' | 'mob'
name: string
slug: string
icon: string
color: string
maxHealth: number
attacks: Iwt2AdminAttack[]
assets: Iwt2AdminAsset[]
}
type AdminTab = 'items' | 'dungeons' | 'encounters' | 'loot' | 'crafting' | 'upgrades' | 'classes' | 'iwt2'
type SavingState = Record<string, boolean>
type SetData = Dispatch<SetStateAction<AdminData | null>>
type SetSaving = Dispatch<SetStateAction<SavingState>>
@@ -144,6 +181,7 @@ const tabs: { id: AdminTab; label: string }[] = [
{ id: 'crafting', label: 'Crafting' },
{ id: 'upgrades', label: 'Upgrades' },
{ id: 'classes', label: 'Classes' },
{ id: 'iwt2', label: 'IWT2' },
]
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -189,6 +227,7 @@ export function AdminScreen({ onBack }: { onBack: () => void }) {
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'upgrades' && <UpgradesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
{tab === 'classes' && <ClassesTab data={data} />}
{tab === 'iwt2' && <Iwt2AdminTab />}
</section>
)
}
@@ -1155,6 +1194,196 @@ function ClassesTab({ data }: { data: AdminData }) {
)
}
function Iwt2AdminTab() {
const [metadata, setMetadata] = useState<Record<Iwt2BossId, Iwt2BossMetadata>>(IWT2_BOSS_METADATA)
const [adminData, setAdminData] = useState<Iwt2AdminData | null>(null)
const [error, setError] = useState('')
const [selectedEntityId, setSelectedEntityId] = useState<string>('bulldrome')
const [draftHp, setDraftHp] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
useEffect(() => {
fetchJson<Iwt2AdminData>(`${API}/iwt2/data`)
.then((result) => {
setAdminData(result)
setMetadata(applyIwt2AdminOverrides(IWT2_BOSS_METADATA, result.balanceOverrides))
})
.catch((e: unknown) => {
setError(e instanceof Error ? e.message : 'Failed to load IWT2 admin data')
setAdminData({ balanceOverrides: { bosses: {} }, bossAssets: [] })
})
}, [])
const rows = createIwt2AdminRows(metadata, adminData?.bossAssets ?? [])
const selectedRow = rows.find((row) => row.id === selectedEntityId) ?? rows[0]
async function saveHp(row: Iwt2AdminEntityRow) {
const value = Number(draftHp[row.id] ?? row.maxHealth)
if (!Number.isFinite(value) || value <= 0) {
alert('HP must be greater than zero.')
return
}
const nextHealth = Math.round(value)
setSaving((prev) => ({ ...prev, [row.id]: true }))
try {
await fetchJson(`${API}/iwt2/entities/${encodeURIComponent(row.id)}/hp`, jsonRequest('PUT', { maxHealth: nextHealth }))
setMetadata((current) => {
const boss = current[row.bossId]
return {
...current,
[row.bossId]: row.kind === 'boss'
? { ...boss, maxHealth: nextHealth }
: { ...boss, birdHealth: nextHealth },
}
})
setDraftHp((prev) => {
const next = { ...prev }
delete next[row.id]
return next
})
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving((prev) => ({ ...prev, [row.id]: false }))
}
}
return (
<div className="admin-panel iwt2-admin-panel">
{error && <p className="error-message">{error}</p>}
<div className="iwt2-admin-summary">
<span>{rows.length} mobs / bosses</span>
<span>{adminData?.bossAssets.length ?? 0} uploaded boss assets</span>
<span>HP edits write to IWT2 balance overrides</span>
</div>
<div className="iwt2-admin-layout">
<aside className="iwt2-admin-entity-list" aria-label="IWT2 mobs and bosses">
{rows.map((row) => (
<button
className={selectedRow?.id === row.id ? 'active' : ''}
key={row.id}
onClick={() => setSelectedEntityId(row.id)}
type="button"
>
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
<span>
<strong>{row.name}</strong>
<small>{row.kind} · {row.maxHealth} hp · {row.attacks.length} attacks</small>
</span>
</button>
))}
</aside>
{selectedRow && (
<Iwt2AdminDetail
draftHp={draftHp}
row={selectedRow}
saveHp={saveHp}
saving={saving}
setDraftHp={setDraftHp}
/>
)}
</div>
</div>
)
}
function Iwt2AdminDetail({
draftHp,
row,
saveHp,
saving,
setDraftHp,
}: {
draftHp: Record<string, string>
row: Iwt2AdminEntityRow
saveHp: (row: Iwt2AdminEntityRow) => void
saving: Record<string, boolean>
setDraftHp: Dispatch<SetStateAction<Record<string, string>>>
}) {
const value = draftHp[row.id] ?? String(row.maxHealth)
const changed = Number(value) !== row.maxHealth
return (
<section className="iwt2-admin-detail">
<header className="iwt2-admin-detail-hero">
<span className="iwt2-admin-token" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>{row.icon}</span>
<div>
<p className="eyebrow">{row.kind}</p>
<h2>{row.name}</h2>
<small>{row.slug} · source boss: {row.bossId}</small>
</div>
</header>
<label className="iwt2-admin-hp">
HP
<input
min="1"
step="1"
type="number"
value={value}
onChange={(event) => setDraftHp((prev) => ({ ...prev, [row.id]: event.target.value }))}
/>
<button
className="primary-button"
disabled={!changed || saving[row.id]}
onClick={() => saveHp(row)}
type="button"
>
{saving[row.id] ? 'Saving...' : 'Save HP'}
</button>
</label>
<section className="iwt2-admin-section">
<div className="iwt2-admin-section-heading">
<h3>Assets</h3>
<span>{row.assets.length} uploaded</span>
</div>
<div className="iwt2-admin-assets">
<div className="iwt2-admin-swatch" style={{ '--iwt2-entity-color': row.color } as CSSProperties}>
<span>{row.icon}</span>
<strong>Runtime Token</strong>
<small>Icon and color used by current arena renderer.</small>
</div>
{row.assets.length > 0 ? row.assets.map((asset) => {
const label = iwt2AssetLabel(asset.filename)
return (
<a href={asset.url} key={asset.filename} target="_blank" rel="noreferrer">
<img src={asset.url} alt={`${row.name} ${label}`} />
<strong>{label}</strong>
<small>{asset.filename}</small>
</a>
)
}) : (
<p className="admin-item-desc">No uploaded raster asset matched. Runtime uses shape, icon, and color.</p>
)}
</div>
</section>
<section className="iwt2-admin-section">
<div className="iwt2-admin-section-heading">
<h3>Attacks</h3>
<span>{row.attacks.length} mechanics</span>
</div>
<div className="iwt2-admin-attacks">
<div className="iwt2-admin-attack-head">
<span>Attack</span>
<span>Frequency</span>
<span>Damage</span>
</div>
{row.attacks.map((attack) => (
<div className="iwt2-admin-attack-row" key={`${row.id}-${attack.name}`}>
<strong>{attack.name}</strong>
<span>{attack.frequency}</span>
<span>{attack.damage}</span>
<small>{attack.details}</small>
</div>
))}
</div>
</section>
</section>
)
}
function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
return {
method,
@@ -1163,6 +1392,136 @@ function jsonRequest(method: 'POST' | 'PUT', body: unknown): RequestInit {
}
}
function applyIwt2AdminOverrides(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
overrides: Iwt2BalanceOverrides,
): Record<Iwt2BossId, Iwt2BossMetadata> {
return {
bulldrome: {
...metadata.bulldrome,
...overrides.bosses?.bulldrome,
},
'yian-kut-ku': {
...metadata['yian-kut-ku'],
...overrides.bosses?.['yian-kut-ku'],
},
}
}
function createIwt2AdminRows(
metadata: Record<Iwt2BossId, Iwt2BossMetadata>,
bossAssets: Iwt2AdminAsset[],
): Iwt2AdminEntityRow[] {
const bosses = Object.values(metadata).map((boss): Iwt2AdminEntityRow => ({
assets: assetsForIwt2Boss(boss.id, bossAssets),
attacks: bossAttacks(boss),
bossId: boss.id,
color: boss.color,
icon: boss.icon,
id: boss.id,
kind: 'boss',
maxHealth: boss.maxHealth,
name: boss.name,
slug: boss.id,
}))
const yian = metadata['yian-kut-ku']
if (!yian.birdHealth) return bosses
return [
...bosses,
{
assets: [],
attacks: yianBirdAttacks(yian),
bossId: yian.id,
color: '#f0b84f',
icon: 'v',
id: `${yian.id}:yian-bird`,
kind: 'mob',
maxHealth: yian.birdHealth,
name: 'Yian Kut Ku Hatchling',
slug: 'yian-bird',
},
]
}
function bossAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
const attacks: Iwt2AdminAttack[] = [{
damage: `${boss.meleeDamage}`,
details: `Range ${boss.meleeRange}px`,
frequency: `${formatSeconds(boss.meleeCooldown)} cooldown`,
name: 'Melee',
}]
if (boss.chargeCooldown > 0) {
attacks.push({
damage: `${boss.chargeDamage}`,
details: `${formatSeconds(boss.chargeWindup)} windup, ${formatSeconds(boss.chargeStunSeconds)} stun / knockdown`,
frequency: `${formatSeconds(boss.chargeCooldown)} cooldown`,
name: 'Charge',
})
}
if (boss.slamDamage > 0) {
attacks.push({
damage: `${boss.slamDamage}`,
details: `${boss.slamRadius}px radius, ${formatSeconds(boss.slamStunSeconds)} stun / knockdown`,
frequency: 'Every 3rd charge',
name: 'Ground Slam',
})
}
if (boss.fireballCooldown && boss.fireballDamage) {
attacks.push({
damage: `${boss.fireballDamage} hit + ${boss.firePuddleDamage ?? 0}/tick puddle`,
details: `${boss.firePuddleRadius ?? 0}px puddle for ${formatSeconds(boss.firePuddleSeconds ?? 0)}`,
frequency: `${formatSeconds(boss.fireballCooldown)} cooldown`,
name: 'Fireball',
})
}
if (boss.birdWaveThresholds?.length) {
attacks.push({
damage: `${boss.birdContactDamage ?? 0} contact`,
details: `${boss.birdWaveThresholds.map((threshold) => `${Math.round(threshold * 100)}%`).join(', ')} HP thresholds`,
frequency: 'Health threshold',
name: 'Bird Wave',
})
}
return attacks
}
function yianBirdAttacks(boss: Iwt2BossMetadata): Iwt2AdminAttack[] {
return [
{
damage: `${boss.birdContactDamage ?? 0}`,
details: `Contact radius ${boss.birdRadius ?? 0}px`,
frequency: '1.15s cooldown',
name: 'Peck',
},
{
damage: '10',
details: `${formatSeconds(boss.birdFlightWindup ?? 0)} windup, ${formatSeconds(boss.birdStunSeconds ?? 0)} stun`,
frequency: `${formatSeconds(boss.birdFlightCooldown ?? 0)} cooldown`,
name: 'Flight Lane',
},
]
}
function assetsForIwt2Boss(bossId: Iwt2BossId, bossAssets: Iwt2AdminAsset[]): Iwt2AdminAsset[] {
const terms = bossId.split('-').filter((part) => part.length > 2)
return bossAssets.filter((asset) => {
const filename = asset.filename.toLowerCase()
return filename.includes(bossId) || terms.some((term) => filename.includes(term))
})
}
function iwt2AssetLabel(filename: string) {
const normalized = filename.toLowerCase()
if (normalized.includes('approach')) return 'Approach Illustration'
if (normalized.includes('guardians')) return 'Party Encounter Art'
if (normalized.includes('boss')) return 'Boss Portrait'
return 'Primary Boss Art'
}
function formatSeconds(value: number) {
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0$/, '')}s`
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
+1 -1
View File
@@ -109,7 +109,7 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
].filter(Boolean).join(' ')
const action = targetBinding ? {
binding: targetBinding,
iconStyle: controllerIconStyle ?? 'xbox',
iconStyle: controllerIconStyle ?? 'playstation',
} : null
return (