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 ShipLivePreview React component (named export).
  • A <canvas> element nested inside a full-size <div> container.
  • A requestAnimationFrame loop driving the per-frame paint.
  • A ResizeObserver that resizes the canvas backing store to its displayed size times devicePixelRatio (capped at 2).
  • The propsRef ref that mirrors the latest rarityColor and rarityAccent props so the RAF loop always reads current values.
  • The startRef time origin used to compute the elapsed-seconds parameter for the bob and sway animation.
  • A visibilitychange listener that cancels the RAF when document.hidden and 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-loader to 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 rarityColor at opacity 44 and rarityAccent at opacity 22 with a transparent outer stop, then draws the sprite with ctx.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/height attributes and inline style.width/style.height set during resize().

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.current null (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 rarityColor or rarityAccent in its animation-loop useEffect dependency array — those updates flow through propsRef, and the dependency-array exhaustive-deps rule is intentionally disabled.

Signals

  • hull changing triggers the sprite-loading effect, which nulls imgRef.current and starts a fresh Image load. Until the new image loads, the RAF keeps running and paints only the gradient.
  • rarityColor / rarityAccent changing is picked up on the next frame via propsRef — no remount, no effect re-run.
  • visibilitychange toggles the RAF loop and resets startRef to performance.now() on resume so the animation does not jump forward by the hidden duration.
  • ResizeObserver on the container fires resize() 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 ShipLivePreview taking { 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 reads propsRef.current per 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 useEffect with an inline eslint-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.current is 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 a visibilitychange.
  • 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.025 at 1.5 rad/s; sway is 0.05 rad at 0.6 rad/s. Both derived from elapsed seconds since the last startRef reset.
  • 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: relative with full width and height; the canvas is display: block so it has no inline-layout descender.