PURPOSE
Canvas-rendered live preview of a ship hull for the ships screen. Mimics the in-game ship render without spinning up the full mission engine: loads the v4 diffuse sprite, runs a RAF loop, and paints a radial rarity-tinted gradient backdrop and the ship sprite with a gentle bob plus sway and a rarity-tinted drop-shadow. Auto-pauses when the tab is hidden.
OWNS
- The
ShipLivePreviewReact component (named export). - A
<canvas>element nested inside a full-size<div>container. - A
requestAnimationFrameloop driving the per-frame paint. - A
ResizeObserverthat resizes the canvas backing store to its displayed size timesdevicePixelRatio(capped at 2). - The
propsRefref that mirrors the latestrarityColorandrarityAccentprops so the RAF loop always reads current values. - The
startReftime origin used to compute the elapsed-seconds parameter for the bob and sway animation. - A
visibilitychangelistener that cancels the RAF whendocument.hiddenand restarts it (resetting the time origin) when the tab is visible again.
READS FROM
- Props:
hull(string),rarityColor(string, hex/CSS),rarityAccent(string, hex/CSS). getShipV4SpritePath(hull)from@starship-survivors/engine/rendering/ships-v4-loaderto resolve the diffuse sprite URL for the supplied hull.window.devicePixelRatio,document.hidden,performance.now(),requestAnimationFrame/cancelAnimationFrame.- The container’s
getBoundingClientRect()for layout-driven canvas sizing.
PUSHES TO
- The canvas 2D context. Per frame it clears, paints a radial gradient using
rarityColorat opacity44andrarityAccentat opacity22with a transparent outer stop, then draws the sprite withctx.filter = drop-shadow(0 0 16px ${rarityColor}66)applied while translated to the bobbed center and rotated by the current sway. - The DOM, only via the canvas’s intrinsic
width/heightattributes and inlinestyle.width/style.heightset duringresize().
DOES NOT
- Does not mount the mission engine, particle systems, shield rings, or thruster glow — the comment block calls these out and the current implementation explicitly omits them despite mentioning them in the file header.
- Does not consume any Zustand store or context — it is fully prop-driven.
- Does not preload or cache sprites across instances; each mount creates a new
Image. - Does not handle sprite load failure beyond leaving
imgRef.currentnull (only the gradient is painted in that case). - Does not run the RAF when the tab is hidden; it cancels and restarts on
visibilitychange. - Does not depend on
rarityColororrarityAccentin its animation-loopuseEffectdependency array — those updates flow throughpropsRef, and the dependency-array exhaustive-deps rule is intentionally disabled.
Signals
hullchanging triggers the sprite-loading effect, which nullsimgRef.currentand starts a freshImageload. Until the new image loads, the RAF keeps running and paints only the gradient.rarityColor/rarityAccentchanging is picked up on the next frame viapropsRef— no remount, no effect re-run.visibilitychangetoggles the RAF loop and resetsstartReftoperformance.now()on resume so the animation does not jump forward by the hidden duration.ResizeObserveron the container firesresize()whenever the layout changes, keeping the canvas crisp at the current DPR.
Entry points
- Default React import:
import { ShipLivePreview } from '.../screens/ships/ShipLivePreview'. - Single named export
ShipLivePreviewtaking{ hull, rarityColor, rarityAccent }. - No imperative handle, no ref forwarding, no portal — drop it into a sized parent and it fills it.
Pattern notes
- Props-on-a-ref pattern:
propsRef.current = { rarityColor, rarityAccent }is reassigned on every render, then the RAF loop readspropsRef.currentper frame. This keeps the animation effect’s dependency array empty (mount-once) while still reacting to live prop changes. - Empty dependency array on the animation
useEffectwith an inlineeslint-disable-next-line react-hooks/exhaustive-deps— intentional, the loop is mounted once and torn down on unmount. - Sprite-load race fix: the RAF schedules the next frame unconditionally, including frames where
imgRef.currentis still null. The inline comment notes that without this the loop silently dies when the image load beats the first RAF and only restarts on avisibilitychange. - DPR is capped at 2 (
Math.min(2, window.devicePixelRatio || 1)) to bound backing-store cost on high-DPR phones. - Sprite size is
Math.min(w, h) * 1.008— about 40 percent larger than the prior 0.72 value, sized so the ship visually dominates the card. - Bob amplitude is
h * 0.025at 1.5 rad/s; sway is0.05rad at 0.6 rad/s. Both derived from elapsed seconds since the laststartRefreset. - Gradient opacity is encoded by appending two-hex alpha suffixes (
44,22,66) onto the incoming color strings, which assumes those props are six-digit hex strings. - The container is
position: relativewith full width and height; the canvas isdisplay: blockso it has no inline-layout descender.