feat(assets): creature artwork from the shard's own client (Phase 3)
Until now the only way a creature got a picture on this site was for an
operator to open UOFiddler on a desktop, export sprites by hand, copy them to
the web host and write a spawnAtlas.art.json naming each one. Almost nobody
did, so shard_spawn_creatures.art was NULL on every install.
The shard has had those files the whole time. Admin -> Shard -> Import now
walks its asset manifest, fetches only the sprites whose hash changed, writes
them under uploads/atlas/, asks the shard for a body id per atlas creature
(§8: it CONSTRUCTS the creature and reads Body.BodyID, which is the only thing
that is right for a shard's own custom creatures) and points each creature at
its picture. On a stock client that is 787 portraits, about a megabyte.
**The one thing v8.md §12 got wrong, and it is not cosmetic.** It says
`shard_spawn_creatures.art` "starts being filled by the import". That table is
emptied and refilled by replaceAtlas on EVERY atlas refresh, and a refresh runs
on every boot -- so a filename stored there would be destroyed by an ordinary
re-parse of the ServUO tree, with the next Update finding the client files
unchanged, reporting "nothing to do", and never restoring it. Nothing would
report a fault; the pictures would just be gone.
So the assets and the body map live in their own tables outside that blast
radius, and applyAtlas re-derives `art` on the way past as
`{ ...derived, ...operatorMap }` -- which is also the one place "the operator's
own artwork wins" is enforced, on every rebuild rather than only at import.
Smaller decisions worth not rediscovering:
- The derivation joins on the catalogue KEY, not on the body id. The simpler
join is correct today and stops being correct the moment phase 6 adds
body/400/a2/f0, at which point one slug matches dozens of rows.
- Filenames are content-addressed. A stable name overwritten in place leaves
every browser and CDN serving the previous client's sprite, with the database
row perfectly correct.
- An unchanged key whose FILE is missing is fetched again. The row and the disk
can disagree (a wiped uploads volume, a restore from a dump), and a broken
image on a creature page is worse than one re-fetched sprite.
- A key the shard cannot render is not a failure. Two thirds of the playable
ghost and gargoyle bodies have no art on a stock client, and an import that
reported eight failures every time would teach an operator to ignore the panel.
- A key that VANISHED from the manifest needs review before anything changes:
an unmounted client volume and a deliberate downgrade look identical here.
23 new tests; 674 server and 42 client tests pass. The SQL was also run against
a real MariaDB, which is what proved the CONCAT join and the singleton CHECK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -45,6 +45,46 @@ function Chip({ active, onClick, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One creature's portrait, when there is one.
|
||||
//
|
||||
// `art` is a FILENAME under uploads/atlas/, never a path or a URL: it is either a
|
||||
// sprite the shard extracted from the operator's own UO client (docs/link/v8.md
|
||||
// §12) or a picture the operator drew and named in `spawnAtlas.art.json`, and the
|
||||
// two are indistinguishable here on purpose.
|
||||
//
|
||||
// **NULL is the ordinary case and always will be.** An install with no shard link
|
||||
// has never imported one; a shard whose host cannot render images has none; and
|
||||
// even on a complete import, two thirds of the playable ghost and gargoyle bodies
|
||||
// have no art in the client at all (§5.2). So this renders nothing rather than a
|
||||
// placeholder, and every layout around it is written to sit correctly with the
|
||||
// picture absent — which is the state the whole atlas was designed in.
|
||||
//
|
||||
// Sprites are small (a couple of dozen pixels square) and UO's art is pixel art,
|
||||
// so `imageRendering: 'pixelated'` matters: a browser's default smoothing turns a
|
||||
// 24×63 wolf into a smear at any size above its own.
|
||||
export function CreaturePortrait({ art, name, size = 40 }) {
|
||||
if (!art) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`/uploads/atlas/${encodeURIComponent(art)}`}
|
||||
alt=""
|
||||
// Decorative: the creature's name is already beside it as text, so an alt
|
||||
// repeating it would make a screen reader say it twice.
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flex: 'none',
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
title={name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
@@ -60,6 +100,7 @@ function CreatureCard({ creature }) {
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<CreaturePortrait art={creature.art} name={creature.name} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import { CreaturePortrait } from './Atlas.jsx'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
@@ -146,11 +147,22 @@ export default function AtlasCreature() {
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
{/* The portrait sits BESIDE the header rather than inside it: `art`
|
||||
is NULL for most creatures on most installs — no shard link, a
|
||||
host that cannot render images, or simply a body this client has
|
||||
no art for — and a header component that had to lay out around an
|
||||
absent picture would be carrying that case forever. Here the row
|
||||
collapses to exactly the header, which is what it was before. */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<CreaturePortrait art={data.art} name={data.name} size={96} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
|
||||
Reference in New Issue
Block a user