import { z } from "zod"; /** * Map presentation settings (CR-003): sizes in screen pixels, offsets, and * colors. These are not game numbers, so they live here, not in * `@deadhead/content`. Angles are degrees clockwise from screen right. */ const px = z.number().positive(); const color = z.number().int().min(0).max(0xffffff); const offset = z.object({ distancePx: px, angleDeg: z.number() }); export const mapStyleSchema = z .object({ /** On-screen base size (long side, px) per sprite asset id (decision 0.5.38). */ spriteSizePx: z.record(z.string(), px), /** Every ship sprite's base size (long side, px). */ shipSizePx: px, /** * Sprite size factor as the player zooms (decision 0.5.38): zoom ^ exponent, * clamped to [min, max], so sprites stay roughly the same size on screen. */ spriteScale: z.object({ min: px, max: px, zoomExponent: z.number().min(0).max(1) }), /** * Child bodies drawn at a fixed screen distance from their parent (decision * 0.5.39). The angle is measured from the outward direction (Sun → parent), * so a child never lands on the Sun. */ childOffsets: z.record(z.string(), offset.extend({ parentId: z.string() })), /** * Idle-ship count badge: `distancePx` beyond the body's edge, at `angleDeg`, * or the first of the other diagonals that is clear of bodies and labels. */ idleBadge: offset.extend({ radiusPx: px, fontSizePx: px }), /** Location badge art (e.g. Jupiter's cloud platform), on the body's edge. */ locationBadge: z.object({ sizePx: px, angleDeg: z.number() }), /** Opacity of an undiscovered location's (desaturated) sprite. */ undiscoveredAlpha: z.number().min(0).max(1), /** Code-drawn lock (undiscovered) and HQ marker sizes, px. */ lockSizePx: px, hqSizePx: px, /** No tap target is smaller than this (px, square). */ minTapPx: px, /** Background moves this fraction of the camera pan (decision 0.5.40). */ parallax: z.number().min(0).max(1), /** Background covers the viewport plus this fraction of it on every side. */ backgroundMargin: z.number().min(0).max(1), /** Background zoom follows map zoom by this fraction (0 = never zooms). */ backgroundZoomFollow: z.number().min(0).max(1), label: z.object({ fontSizePx: px, gapPx: z.number().min(0), haloPx: z.number().min(0) }), /** Gap kept between in-flight ships that would otherwise overlap (Step 10C.8). */ shipSeparationPx: px, colors: z.object({ background: color, orbit: color, orbitAlpha: z.number().min(0).max(1), routeBehind: color, routeAhead: color, label: color, labelUndiscovered: color, labelHalo: color, hq: color, badgeFill: color, badgeText: color, selection: color, lock: color, placeholderBody: color, placeholderShip: color, sun: color, }), }) .refine((s) => s.spriteScale.min <= s.spriteScale.max, { message: "spriteScale.min must not exceed spriteScale.max", path: ["spriteScale"], }); export type MapStyle = z.infer; export const mapStyle: MapStyle = mapStyleSchema.parse({ spriteSizePx: { sun: 64, "body-jupiter": 48, "body-earth": 32, "body-mars": 32, "body-ceres": 22, "body-moon": 18, }, shipSizePx: 24, spriteScale: { min: 0.75, max: 1.75, zoomExponent: 0.35 }, // 46 px clears Earth and the Moon at the largest sprite scale: (32 + 18) / 2 × 1.75 = 43.75. childOffsets: { moon: { parentId: "earth", distancePx: 46, angleDeg: -30 } }, idleBadge: { distancePx: 2, angleDeg: -135, radiusPx: 8, fontSizePx: 10 }, locationBadge: { sizePx: 16, angleDeg: -45 }, undiscoveredAlpha: 0.55, lockSizePx: 12, hqSizePx: 9, minTapPx: 44, parallax: 0.15, backgroundMargin: 0.1, backgroundZoomFollow: 0.05, label: { fontSizePx: 11, gapPx: 4, haloPx: 3 }, shipSeparationPx: 44, colors: { background: 0x020617, orbit: 0x94a3b8, orbitAlpha: 0.18, routeBehind: 0x38bdf8, routeAhead: 0x7dd3fc, label: 0xe2e8f0, labelUndiscovered: 0x94a3b8, labelHalo: 0x020617, hq: 0x34d399, badgeFill: 0xfbbf24, badgeText: 0x020617, selection: 0xfbbf24, lock: 0xcbd5e1, placeholderBody: 0xe2e8f0, placeholderShip: 0xfbbf24, sun: 0xfbbf24, }, });