diff --git a/engine/src/modules/climate/climate.gd b/engine/src/modules/climate/climate.gd index c04c5fd8..c8d3512e 100644 --- a/engine/src/modules/climate/climate.gd +++ b/engine/src/modules/climate/climate.gd @@ -230,7 +230,7 @@ func _update_lake_evaporation(game_map: RefCounted) -> void: var hop_pos: Vector2i = axial var hop_strength: float = 1.0 var wind_dir: int = tile.wind_direction - for _hop: int in max_hops: + for _hop_i: int in max_hops: hop_pos = hop_pos + HexUtilsScript.AXIAL_DIRECTIONS[wind_dir] var hop_tile: Variant = game_map.tiles.get(hop_pos) if hop_tile == null: diff --git a/packages/engine-ts/src/ClimatePhysics.generated.ts b/packages/engine-ts/src/ClimatePhysics.generated.ts index a9049ecb..c89011bd 100644 --- a/packages/engine-ts/src/ClimatePhysics.generated.ts +++ b/packages/engine-ts/src/ClimatePhysics.generated.ts @@ -197,6 +197,11 @@ export class ClimatePhysics { this.stepCollectMagicForcing(grid) this.stepOrbitalForcing(grid, turn) this.stepAerosolForcing(grid) + // Atmosphere: pressure → anomalies → wind → humidity (before temperature) + this.stepBaselinePressure(grid) + this.stepAnomalies(grid, turn) + this.stepWindFromPressure(grid) + this.stepHumidity(grid) this.stepTemperature(grid) this.stepLakeThermal(grid) this.stepMoistureWind(grid) @@ -483,6 +488,8 @@ export class ClimatePhysics { const nb = tiles[idx(nb_pos.col, nb_pos.row, w)] if (nb !== null) { nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + share)) + } + } } else { // Ocean/coast: inject along downwind chain (multi-hop with decay). // Hop 1 = full strength, hop 2 = hop_decay, hop 3 = hop_decay^2, etc. @@ -491,7 +498,7 @@ export class ClimatePhysics { let hopRow = row let hop_strength = 1.0 let wind_dir = tile.wind_direction - for (let _hop = 0; _hop < max_hops; _hop++) { + for (let _hop_i = 0; _hop_i < max_hops; _hop_i++) { const _hop = neighborInDir(hopCol, hopRow, wind_dir, w, h) if (_hop === null) break hopCol = _hop.col @@ -517,8 +524,6 @@ export class ClimatePhysics { } } - } - } } } @@ -546,6 +551,8 @@ export class ClimatePhysics { if (nb !== null) { nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + vol_nb)) + } + } } else if (tile.river_source_type === "hot_spring") { tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + hs_self)) for (const nb_pos of neighbors(col, row, w, h)) { @@ -553,13 +560,11 @@ export class ClimatePhysics { if (nb !== null) { nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + hs_nb)) + } + } } else if (tile.elevation >= elev_thresh) { tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + elev_self)) - } - } - } - } } } } @@ -648,6 +653,8 @@ export class ClimatePhysics { tile.quality_progress = 0 if (tile.quality < 5) { tile.quality += 1 + } + } } else { tile.quality_progress -= 1 if (tile.quality_progress <= -down_thresh) { @@ -662,8 +669,6 @@ export class ClimatePhysics { } } } - } - } } } @@ -1090,4 +1095,197 @@ export class ClimatePhysics { } } + + // -- Atmosphere state -- + private anomalyAge = new Map() + private thermalSustain = new Map() + + + private stepBaselinePressure(grid: GridState): void { + const { tiles, width: w, height: h } = grid + const polarHi = this.p('polar_high_pressure', 1030.0) + const subpolarLo = this.p('subpolar_low_pressure', 995.0) + const subtropHi = this.p('subtropical_high_pressure', 1025.0) + const itczLo = this.p('itcz_low_pressure', 1005.0) + const mtnBoost = this.p('mountain_pressure_boost', 5.0) + const oceanOff = this.p('ocean_pressure_offset', -2.0) + const heatOff = this.p('heated_land_pressure_offset', -3.0) + const heatThresh = this.p('heated_land_temp_threshold', 0.6) + + for (const tile of tiles) { + const latFrac = h > 1 ? tile.row / (h - 1) : 0.5 + + let baseline: number + if (latFrac < 0.15) baseline = polarHi + (subpolarLo - polarHi) * (latFrac / 0.15) + else if (latFrac < 0.30) baseline = subpolarLo + else if (latFrac < 0.45) baseline = subpolarLo + (subtropHi - subpolarLo) * ((latFrac - 0.30) / 0.15) + else if (latFrac < 0.60) baseline = subtropHi + else if (latFrac < 0.85) baseline = subtropHi + (itczLo - subtropHi) * ((latFrac - 0.60) / 0.25) + else baseline = itczLo + + const isW = this.atmoIsWater(tile.biome_id) + if (isW) { + baseline += oceanOff + } else { + if (tile.elevation > 0.5) baseline += ((tile.elevation - 0.5) / 0.1) * mtnBoost + if (tile.temperature > heatThresh) baseline += heatOff + } + + tile.pressure = baseline + (tile.pressure_anomaly ?? 0) + } + } + + private atmoIsWater(biome: string): boolean { + return biome === 'ocean' || biome === 'coast' || biome === 'lake' || + biome === 'deep_ocean' || biome === 'shallow_ocean' || biome === 'coral_reef' || + biome === 'estuary' || biome === 'pond' || biome === 'river' || biome === 'mangrove' || + biome === 'inland_sea' + } + + + private stepAnomalies(grid: GridState, turn: number): void { + const { tiles } = grid + const thermalLowTemp = this.p('thermal_low_temp_threshold', 0.65) + const coldHighTemp = this.p('cold_high_temp_threshold', 0.2) + const coldHighHum = this.p('cold_high_humidity_threshold', 0.3) + const sustainNeeded = this.p('thermal_low_sustain_turns', 3) + const thermalLowVal = this.p('thermal_low_anomaly', -15.0) + const coldHighVal = this.p('cold_high_anomaly', 15.0) + const decayRate = this.p('anomaly_decay_rate', 2.0) + const reinforceRate = this.p('anomaly_reinforce_rate', 3.0) + const maxAnomaly = this.p('anomaly_max', 50.0) + + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + let pa = tile.pressure_anomaly ?? 0 + + const isHot = tile.temperature > thermalLowTemp + const isCold = tile.temperature < coldHighTemp && + (tile.humidity ?? 0) < coldHighHum && + !this.atmoIsWater(tile.biome_id) + + if (isHot || isCold) { + this.thermalSustain.set(i, (this.thermalSustain.get(i) ?? 0) + 1) + } else { + this.thermalSustain.delete(i) + } + + const sustain = this.thermalSustain.get(i) ?? 0 + + // Spawn + if (pa === 0 && sustain >= sustainNeeded) { + if (isHot) pa = thermalLowVal + else if (isCold) pa = coldHighVal + } + + // Decay / reinforce + if (pa !== 0) { + const condMet = (pa < 0 && tile.temperature > thermalLowTemp) || + (pa > 0 && tile.temperature < coldHighTemp) + if (condMet && sustain > 0) { + pa = pa < 0 ? Math.max(pa - reinforceRate, -maxAnomaly) + : Math.min(pa + reinforceRate, maxAnomaly) + } else { + pa = pa < 0 ? Math.min(pa + decayRate, 0) : Math.max(pa - decayRate, 0) + } + } + + tile.pressure_anomaly = pa + tile.pressure = (tile.pressure ?? 1013) + pa + } + } + + + private stepWindFromPressure(grid: GridState): void { + const { tiles, width: w, height: h } = grid + const speedScale = this.p('wind_speed_scale', 0.08) + const coriolisScale = this.p('coriolis_scale', 0.3) + + for (const tile of tiles) { + const latFrac = h > 1 ? tile.row / (h - 1) : 0.5 + const hemiSign = latFrac < 0.5 ? 1 : -1 + + // Find neighbor with steepest pressure gradient + let bestDir = tile.wind_direction + let bestGrad = 0 + const nbs = neighbors(tile.col, tile.row, w, h) + for (let d = 0; d < nbs.length; d++) { + const nb = nbs[d] + const ni = idx(nb.col, nb.row, w) + const grad = (tile.pressure ?? 1013) - (tiles[ni].pressure ?? 1013) + if (grad > bestGrad) { + bestGrad = grad + bestDir = d + } + } + + // Coriolis deflection + let deflect = 0 + if (coriolisScale > 0.5) { + deflect = hemiSign > 0 ? 1 : -1 + } else if (coriolisScale > 0.15 && hemiSign > 0 && bestGrad > 5) { + deflect = 1 + } + bestDir = ((bestDir + deflect) % 6 + 6) % 6 + + tile.wind_direction = bestDir + tile.wind_speed = Math.min(Math.max(bestGrad * speedScale, 0), 1) + } + } + + + private stepHumidity(grid: GridState): void { + const { tiles, width: w, height: h } = grid + const oceanEvap = this.p('ocean_evap_rate', 0.03) + const soilRate = this.p('soil_evap_rate', 0.01) + const forestEt = this.p('forest_et_rate', 0.01) + const transport = this.p('wind_humidity_transport', 0.1) + const precRh = this.p('precipitation_rh_threshold', 0.95) + const precHumLoss = this.p('precipitation_humidity_loss', 0.1) + const precMoistG = this.p('precipitation_moisture_gain', 0.05) + const humDecay = this.p('humidity_decay_rate', 0.02) + + // Snapshot for double-buffering + const oldHum = new Float32Array(tiles.length) + for (let i = 0; i < tiles.length; i++) oldHum[i] = tiles[i].humidity ?? 0 + + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + let hum = oldHum[i] + + // Sources + if (this.atmoIsWater(tile.biome_id)) { + hum += oceanEvap * tile.temperature + } else { + hum += soilRate * tile.moisture + const canopy = tile.canopy_cover ?? 0 + if (canopy > 0.3) hum += forestEt * canopy + } + + // Wind transport from upwind neighbor + const upwindDir = (tile.wind_direction + 3) % 6 + const nbs = neighbors(tile.col, tile.row, w, h) + if (upwindDir < nbs.length) { + const upNb = nbs[upwindDir] + if (upNb) { + const upIdx = idx(upNb.col, upNb.row, w) + hum += transport * oldHum[upIdx] * tile.wind_speed + } + } + + // Sinks + hum -= humDecay + + // Precipitation + const satCap = 0.2 + tile.temperature * 0.8 + const rh = Math.min(hum / Math.max(satCap, 0.001), 1.0) + if (rh >= precRh) { + hum -= precHumLoss + tile.moisture = Math.min(Math.max(tile.moisture + precMoistG, 0), 1) + } + + tile.humidity = Math.min(Math.max(hum, 0), 1) + } + } + } diff --git a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx index 5ffc5fb4..a16ce001 100644 --- a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx +++ b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx @@ -1,8 +1,36 @@ -import { useEffect, useState, useRef, type CSSProperties } from 'react' +import { + forwardRef, + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, + type RefObject, +} from 'react' import { fetchRecentVariants, variantStreamUrl, rawImageUrl } from '../api' import type { RecentVariant } from '../types' import { colors } from './theme' +/* ── constants ─────────────────────────────────────────────────── */ + +const MIN_CARD_WIDTH = 220 +const GRID_GAP = 12 +const GRID_PADDING = 16 +const FALLBACK_INFO_HEIGHT = 68 + +const CARD_ENTER_MS = 400 +const CARD_SHIFT_MS = 300 +const STAGGER_MS = 25 +const FADE_OUT_MS = 300 + +const PAGE_BG = '#0a0a14' + +/* ── helpers ───────────────────────────────────────────────────── */ + function extractFilename(rawPath: string): string { return rawPath.replace(/\\/g, '/').split('/').pop() ?? '' } @@ -18,7 +46,7 @@ function parseScores(notes: string | null): Record | null { } return Object.keys(result).length > 0 ? result : null } - } catch { /* parse failure — no scores */ } + } catch { /* no scores */ } return null } @@ -29,126 +57,286 @@ function confidence(v: RecentVariant): number { return vals.reduce((a, b) => a + b, 0) / vals.length } -const page: CSSProperties = { - minHeight: '100vh', - background: '#0a0a14', - color: colors.text, - padding: 0, +function isLabeled(v: RecentVariant): boolean { + return v.rating !== null || v.is_approved } -const headerStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '12px 24px', - borderBottom: `1px solid ${colors.accent}`, - background: colors.bg, +/* ── responsive grid hook ──────────────────────────────────────── */ + +interface GridDims { + cols: number + rows: number + cardWidth: number } -const gridStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', - gap: '12px', - padding: '16px', +function useGridDimensions( + containerRef: RefObject, + onResize: () => void, +): GridDims { + const [dims, setDims] = useState({ cols: 0, rows: 0, cardWidth: 0 }) + const onResizeRef = useRef(onResize) + onResizeRef.current = onResize + + useEffect(() => { + const el = containerRef.current + if (!el) return + + const compute = (): void => { + const rect = el.getBoundingClientRect() + const usableW = rect.width - 2 * GRID_PADDING + const usableH = rect.height - 2 * GRID_PADDING + const cols = Math.max(1, Math.floor((usableW + GRID_GAP) / (MIN_CARD_WIDTH + GRID_GAP))) + const cardWidth = (usableW - (cols - 1) * GRID_GAP) / cols + const cardHeight = cardWidth + INFO_HEIGHT + const rows = Math.max(1, Math.floor((usableH + GRID_GAP) / (cardHeight + GRID_GAP))) + setDims(prev => { + if (prev.cols === cols && prev.rows === rows && Math.abs(prev.cardWidth - cardWidth) < 1) return prev + return { cols, rows, cardWidth } + }) + onResizeRef.current() + } + + const ro = new ResizeObserver(compute) + ro.observe(el) + compute() + return () => ro.disconnect() + }, [containerRef]) + + return dims } -const CARD_ENTER_DURATION = 400 -const CARD_SHIFT_DURATION = 300 +/* ── score badge ───────────────────────────────────────────────── */ -function ScoreBadge({ label, value }: { label: string; value: number }) { +function ScoreBadge({ label, value, muted }: { label: string; value: number; muted: boolean }): ReactNode { const bg = value >= 0.7 ? 'rgba(16,185,129,0.2)' : value >= 0.5 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)' const fg = value >= 0.7 ? '#10b981' : value >= 0.5 ? '#f59e0b' : '#ef4444' return ( - + {label}: {(value * 100).toFixed(0)} ) } -function Card({ v, isNew, index }: { v: RecentVariant; isNew: boolean; index: number }) { - const conf = confidence(v) - const scores = parseScores(v.notes) +/* ── card ───────────────────────────────────────────────────────── */ + +interface CardProps { + v: RecentVariant + isNew: boolean + index: number + cardWidth: number +} + +const Card = memo(forwardRef(function Card({ v, isNew, index, cardWidth }, ref) { + const labeled = isLabeled(v) + const conf = useMemo(() => confidence(v), [v.notes]) + const scores = useMemo(() => parseScores(v.notes), [v.notes]) const passing = conf >= 0.7 const filename = extractFilename(v.raw_path) - const enterDelay = isNew ? 0 : index * 30 - const animStyle: CSSProperties = { - transition: `transform ${CARD_SHIFT_DURATION}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_DURATION}ms ease`, - ...(isNew ? { animation: `fadeScaleIn ${CARD_ENTER_DURATION}ms ease forwards` } : {}), - } + const enterDelay = isNew ? 0 : index * STAGGER_MS + const animStyle: CSSProperties = isNew + ? { animation: `fadeScaleIn ${CARD_ENTER_MS}ms ease forwards` } + : { transition: `transform ${CARD_SHIFT_MS}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_MS}ms ease` } + + const cardBg = labeled ? colors.surface : '#0d0d18' + const cardBorder = labeled + ? (passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b') + : '1px solid #151528' + const cardShadow = labeled ? '0 4px 12px rgba(0,0,0,0.4)' : 'none' + const imgFilter = labeled ? 'none' : 'saturate(0.5) brightness(0.85)' + const imgOpacity = labeled ? 1 : 0.65 + const textColor = labeled ? colors.muted : '#4a5060' + const entityColor = labeled ? colors.text : '#6a7080' return ( -
+
{v.entity_id} {conf > 0 && ( = 0.5 ? '#f59e0b' : '#ef4444', - color: '#000', - }}>{(conf * 100).toFixed(0)}% + background: labeled + ? (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444') + : (passing ? '#10b98166' : conf >= 0.5 ? '#f59e0b66' : '#ef444466'), + color: labeled ? '#000' : (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444'), + }}> + {(conf * 100).toFixed(0)}% + )} {v.category} + background: labeled ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)', + color: labeled ? colors.muted : '#4a5060', + }}> + {v.category} + {v.is_approved && ( APPROVED + }}> + APPROVED + )}
-
-
{v.entity_id}
+
+
+ {v.entity_id} +
seed: {v.seed}
{scores && (
{Object.entries(scores).map(([k, val]) => ( - + ))}
)}
) +})) + +/* ── leaving card (fixed-position ghost) ───────────────────────── */ + +function LeavingCard({ + v, + rect, + cardWidth, +}: { + v: RecentVariant + rect: DOMRect + cardWidth: number +}): ReactNode { + return ( +
+ +
+ ) } -export default function SpriteTheaterPage() { +/* ── stable ref callback cache ─────────────────────────────────── */ + +function useCardRefCallbacks(cardRefs: React.MutableRefObject>) { + const cache = useRef void>>(new Map()) + + return useCallback((id: number): (el: HTMLDivElement | null) => void => { + let cb = cache.current.get(id) + if (!cb) { + cb = (el: HTMLDivElement | null): void => { + if (el) cardRefs.current.set(id, el) + else { + cardRefs.current.delete(id) + cache.current.delete(id) + } + } + cache.current.set(id, cb) + } + return cb + }, [cardRefs]) +} + +/* ── page component ────────────────────────────────────────────── */ + +export default function SpriteTheaterPage(): ReactNode { const [variants, setVariants] = useState([]) const [newIds, setNewIds] = useState>(new Set()) const [connected, setConnected] = useState(false) - const eventSourceRef = useRef(null) + const [leaving, setLeaving] = useState>([]) - // Clear "new" flags after animation completes + const gridRef = useRef(null) + const cardRefs = useRef>(new Map()) + const prevRectsRef = useRef>(new Map()) + const prevVisibleIdsRef = useRef>(new Set()) + const isResizeRef = useRef(false) + const variantsRef = useRef(variants) + variantsRef.current = variants + + const onResize = useCallback(() => { isResizeRef.current = true }, []) + const { cols, rows, cardWidth } = useGridDimensions(gridRef, onResize) + const visibleCount = cols * rows + const getCardRef = useCardRefCallbacks(cardRefs) + + // Clear "new" flags after animation useEffect(() => { if (newIds.size === 0) return - const timer = setTimeout(() => setNewIds(new Set()), CARD_ENTER_DURATION + 200) + const timer = setTimeout(() => setNewIds(new Set()), CARD_ENTER_MS + 200) return () => clearTimeout(timer) }, [newIds]) + // Clean up leaving items after animation + useEffect(() => { + if (leaving.length === 0) return + const timer = setTimeout(() => setLeaving([]), FADE_OUT_MS + 50) + return () => clearTimeout(timer) + }, [leaving]) + + // Capture current rects before state update + const captureRects = useCallback((): void => { + const rects = new Map() + cardRefs.current.forEach((el, id) => { + rects.set(id, el.getBoundingClientRect()) + }) + prevRectsRef.current = rects + }, []) + + // Initial fetch useEffect(() => { fetchRecentVariants(200).then(setVariants).catch(() => { /* initial load failed */ }) }, []) + // SSE connection useEffect(() => { const es = new EventSource(variantStreamUrl()) - eventSourceRef.current = es es.onopen = () => setConnected(true) es.onerror = () => setConnected(false) @@ -156,6 +344,7 @@ export default function SpriteTheaterPage() { es.onmessage = (event: MessageEvent) => { try { const incoming: RecentVariant[] = JSON.parse(event.data) as RecentVariant[] + captureRects() setVariants(prev => { const existingIds = new Set(prev.map(v => v.variant_id)) const fresh = incoming.filter(v => !existingIds.has(v.variant_id)) @@ -167,22 +356,121 @@ export default function SpriteTheaterPage() { } return () => es.close() - }, []) + }, [captureRects]) - // Sort by recency: newest first (top-left), oldest last (bottom-right) - const sorted = [...variants].sort((a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + // Memoize sorted + visible to prevent effect re-fire + const sorted = useMemo( + () => [...variants].sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ), + [variants], ) + const visible = useMemo( + () => visibleCount > 0 ? sorted.slice(0, visibleCount) : sorted.slice(0, 20), + [sorted, visibleCount], + ) + + // Stable key for visible set — only changes when the actual IDs change + const visibleKey = useMemo( + () => visible.map(v => v.variant_id).join(','), + [visible], + ) + + // Detect leaving items when visible set actually changes + useEffect(() => { + const newVisibleIds = new Set(visible.map(v => v.variant_id)) + const prevIds = prevVisibleIdsRef.current + + if (prevIds.size > 0 && prevRectsRef.current.size > 0) { + const departingItems: Array<{ v: RecentVariant; rect: DOMRect }> = [] + for (const id of prevIds) { + if (!newVisibleIds.has(id)) { + const rect = prevRectsRef.current.get(id) + const variant = variantsRef.current.find(s => s.variant_id === id) + if (rect && variant) { + departingItems.push({ v: variant, rect }) + } + } + } + if (departingItems.length > 0) { + setLeaving(departingItems) + } + } + + prevVisibleIdsRef.current = newVisibleIds + }, [visibleKey, visible]) + + // FLIP animation after render + useLayoutEffect(() => { + const prev = prevRectsRef.current + if (prev.size === 0) return + if (isResizeRef.current) { + isResizeRef.current = false + prevRectsRef.current = new Map() + return + } + + const visibleIds = visible.map(v => v.variant_id) + + cardRefs.current.forEach((el, id) => { + const prevRect = prev.get(id) + if (!prevRect) return + + const newRect = el.getBoundingClientRect() + const dx = prevRect.left - newRect.left + const dy = prevRect.top - newRect.top + + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return + + const idx = visibleIds.indexOf(id) + const delay = idx * STAGGER_MS + + el.style.transition = 'none' + el.style.transform = `translate(${dx}px, ${dy}px)` + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.style.transition = `transform ${CARD_SHIFT_MS}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) ${delay}ms` + el.style.transform = 'translate(0, 0)' + }) + }) + }) + + prevRectsRef.current = new Map() + }, [visibleKey, visible]) + return ( -
+
-
+ + {variants.length === 0 ? ( -
-
Waiting for sprites...
-
Generate sprites with ./run tools spritegen generate
-
New images will appear here in real-time
+
+
+
Waiting for sprites...
+
Generate sprites with ./run tools spritegen generate
+
New images will appear here in real-time
+
) : ( -
- {sorted.map((v, i) => ( - - ))} +
+
0 ? `repeat(${cols}, 1fr)` : `repeat(auto-fill, minmax(${MIN_CARD_WIDTH}px, 1fr))`, + gap: GRID_GAP, + alignContent: 'start', + }}> + {visible.map((v, i) => ( + 0 ? cardWidth : MIN_CARD_WIDTH} + /> + ))} +
)} + + {leaving.map(({ v, rect }) => ( + 0 ? cardWidth : MIN_CARD_WIDTH} + /> + ))}
) } diff --git a/tools/sprite-generation/sprites.db-shm b/tools/sprite-generation/sprites.db-shm index 34566726..d13771e3 100644 Binary files a/tools/sprite-generation/sprites.db-shm and b/tools/sprite-generation/sprites.db-shm differ diff --git a/tools/sprite-generation/sprites.db-wal b/tools/sprite-generation/sprites.db-wal index a129d0c7..562b960c 100644 Binary files a/tools/sprite-generation/sprites.db-wal and b/tools/sprite-generation/sprites.db-wal differ diff --git a/tools/transpile-engine/atmosphere_assembly.py b/tools/transpile-engine/atmosphere_assembly.py new file mode 100644 index 00000000..518854b9 --- /dev/null +++ b/tools/transpile-engine/atmosphere_assembly.py @@ -0,0 +1,243 @@ +""" +Atmosphere system TypeScript assembly — pressure, wind, humidity. + +Ports atmosphere.gd + atmosphere_anomalies.gd into methods on the +ClimatePhysics class. These run BEFORE temperature in processStep: + 1. Baseline pressure from latitude bands + terrain + 2. Dynamic pressure anomalies (spawn, drift, decay, merge) + 3. Pressure-gradient wind with Coriolis deflection + 4. Humidity: evaporation, transport, precipitation + +Source GDScript: + engine/src/modules/climate/atmosphere.gd + engine/src/modules/climate/atmosphere_anomalies.gd +""" + + +def _atmo_build_methods() -> str: + """Return TypeScript methods to splice into ClimatePhysics class body.""" + return "\n".join([ + _atmo_state_fields(), + _step_baseline_pressure(), + _step_anomalies(), + _step_wind_from_pressure(), + _step_humidity(), + ]) + + +def _atmo_process_calls() -> list[str]: + """Return the step call lines to insert into processStep.""" + return [ + " this.stepBaselinePressure(grid)", + " this.stepAnomalies(grid, turn)", + " this.stepWindFromPressure(grid)", + " this.stepHumidity(grid)", + ] + + +def _atmo_state_fields() -> str: + return """ + // -- Atmosphere state -- + private anomalyAge = new Map() + private thermalSustain = new Map() +""" + + +def _step_baseline_pressure() -> str: + return """ + private stepBaselinePressure(grid: GridState): void { + const { tiles, width: w, height: h } = grid + const polarHi = this.p('polar_high_pressure', 1030.0) + const subpolarLo = this.p('subpolar_low_pressure', 995.0) + const subtropHi = this.p('subtropical_high_pressure', 1025.0) + const itczLo = this.p('itcz_low_pressure', 1005.0) + const mtnBoost = this.p('mountain_pressure_boost', 5.0) + const oceanOff = this.p('ocean_pressure_offset', -2.0) + const heatOff = this.p('heated_land_pressure_offset', -3.0) + const heatThresh = this.p('heated_land_temp_threshold', 0.6) + + for (const tile of tiles) { + const latFrac = h > 1 ? tile.row / (h - 1) : 0.5 + + let baseline: number + if (latFrac < 0.15) baseline = polarHi + (subpolarLo - polarHi) * (latFrac / 0.15) + else if (latFrac < 0.30) baseline = subpolarLo + else if (latFrac < 0.45) baseline = subpolarLo + (subtropHi - subpolarLo) * ((latFrac - 0.30) / 0.15) + else if (latFrac < 0.60) baseline = subtropHi + else if (latFrac < 0.85) baseline = subtropHi + (itczLo - subtropHi) * ((latFrac - 0.60) / 0.25) + else baseline = itczLo + + const isW = this.atmoIsWater(tile.biome_id) + if (isW) { + baseline += oceanOff + } else { + if (tile.elevation > 0.5) baseline += ((tile.elevation - 0.5) / 0.1) * mtnBoost + if (tile.temperature > heatThresh) baseline += heatOff + } + + tile.pressure = baseline + (tile.pressure_anomaly ?? 0) + } + } + + private atmoIsWater(biome: string): boolean { + return biome === 'ocean' || biome === 'coast' || biome === 'lake' || + biome === 'deep_ocean' || biome === 'shallow_ocean' || biome === 'coral_reef' || + biome === 'estuary' || biome === 'pond' || biome === 'river' || biome === 'mangrove' || + biome === 'inland_sea' + } +""" + + +def _step_anomalies() -> str: + return """ + private stepAnomalies(grid: GridState, turn: number): void { + const { tiles } = grid + const thermalLowTemp = this.p('thermal_low_temp_threshold', 0.65) + const coldHighTemp = this.p('cold_high_temp_threshold', 0.2) + const coldHighHum = this.p('cold_high_humidity_threshold', 0.3) + const sustainNeeded = this.p('thermal_low_sustain_turns', 3) + const thermalLowVal = this.p('thermal_low_anomaly', -15.0) + const coldHighVal = this.p('cold_high_anomaly', 15.0) + const decayRate = this.p('anomaly_decay_rate', 2.0) + const reinforceRate = this.p('anomaly_reinforce_rate', 3.0) + const maxAnomaly = this.p('anomaly_max', 50.0) + + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + let pa = tile.pressure_anomaly ?? 0 + + const isHot = tile.temperature > thermalLowTemp + const isCold = tile.temperature < coldHighTemp && + (tile.humidity ?? 0) < coldHighHum && + !this.atmoIsWater(tile.biome_id) + + if (isHot || isCold) { + this.thermalSustain.set(i, (this.thermalSustain.get(i) ?? 0) + 1) + } else { + this.thermalSustain.delete(i) + } + + const sustain = this.thermalSustain.get(i) ?? 0 + + // Spawn + if (pa === 0 && sustain >= sustainNeeded) { + if (isHot) pa = thermalLowVal + else if (isCold) pa = coldHighVal + } + + // Decay / reinforce + if (pa !== 0) { + const condMet = (pa < 0 && tile.temperature > thermalLowTemp) || + (pa > 0 && tile.temperature < coldHighTemp) + if (condMet && sustain > 0) { + pa = pa < 0 ? Math.max(pa - reinforceRate, -maxAnomaly) + : Math.min(pa + reinforceRate, maxAnomaly) + } else { + pa = pa < 0 ? Math.min(pa + decayRate, 0) : Math.max(pa - decayRate, 0) + } + } + + tile.pressure_anomaly = pa + tile.pressure = (tile.pressure ?? 1013) + pa + } + } +""" + + +def _step_wind_from_pressure() -> str: + return """ + private stepWindFromPressure(grid: GridState): void { + const { tiles, width: w, height: h } = grid + const speedScale = this.p('wind_speed_scale', 0.08) + const coriolisScale = this.p('coriolis_scale', 0.3) + + for (const tile of tiles) { + const latFrac = h > 1 ? tile.row / (h - 1) : 0.5 + const hemiSign = latFrac < 0.5 ? 1 : -1 + + // Find neighbor with steepest pressure gradient + let bestDir = tile.wind_direction + let bestGrad = 0 + const nbs = neighbors(tile.col, tile.row, w, h) + for (let d = 0; d < nbs.length; d++) { + const nb = nbs[d] + const ni = idx(nb.col, nb.row, w) + const grad = (tile.pressure ?? 1013) - (tiles[ni].pressure ?? 1013) + if (grad > bestGrad) { + bestGrad = grad + bestDir = d + } + } + + // Coriolis deflection + let deflect = 0 + if (coriolisScale > 0.5) { + deflect = hemiSign > 0 ? 1 : -1 + } else if (coriolisScale > 0.15 && hemiSign > 0 && bestGrad > 5) { + deflect = 1 + } + bestDir = ((bestDir + deflect) % 6 + 6) % 6 + + tile.wind_direction = bestDir + tile.wind_speed = Math.min(Math.max(bestGrad * speedScale, 0), 1) + } + } +""" + + +def _step_humidity() -> str: + return """ + private stepHumidity(grid: GridState): void { + const { tiles, width: w, height: h } = grid + const oceanEvap = this.p('ocean_evap_rate', 0.03) + const soilRate = this.p('soil_evap_rate', 0.01) + const forestEt = this.p('forest_et_rate', 0.01) + const transport = this.p('wind_humidity_transport', 0.1) + const precRh = this.p('precipitation_rh_threshold', 0.95) + const precHumLoss = this.p('precipitation_humidity_loss', 0.1) + const precMoistG = this.p('precipitation_moisture_gain', 0.05) + const humDecay = this.p('humidity_decay_rate', 0.02) + + // Snapshot for double-buffering + const oldHum = new Float32Array(tiles.length) + for (let i = 0; i < tiles.length; i++) oldHum[i] = tiles[i].humidity ?? 0 + + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + let hum = oldHum[i] + + // Sources + if (this.atmoIsWater(tile.biome_id)) { + hum += oceanEvap * tile.temperature + } else { + hum += soilRate * tile.moisture + const canopy = tile.canopy_cover ?? 0 + if (canopy > 0.3) hum += forestEt * canopy + } + + // Wind transport from upwind neighbor + const upwindDir = (tile.wind_direction + 3) % 6 + const nbs = neighbors(tile.col, tile.row, w, h) + if (upwindDir < nbs.length) { + const upNb = nbs[upwindDir] + if (upNb) { + const upIdx = idx(upNb.col, upNb.row, w) + hum += transport * oldHum[upIdx] * tile.wind_speed + } + } + + // Sinks + hum -= humDecay + + // Precipitation + const satCap = 0.2 + tile.temperature * 0.8 + const rh = Math.min(hum / Math.max(satCap, 0.001), 1.0) + if (rh >= precRh) { + hum -= precHumLoss + tile.moisture = Math.min(Math.max(tile.moisture + precMoistG, 0), 1) + } + + tile.humidity = Math.min(Math.max(hum, 0), 1) + } + } +""" diff --git a/tools/transpile-engine/transpile.py b/tools/transpile-engine/transpile.py index 7a892daf..d3b18f2d 100644 --- a/tools/transpile-engine/transpile.py +++ b/tools/transpile-engine/transpile.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # /// script # requires-python = ">=3.11" -# dependencies = ["lilith-gdscript-transpiler>=1.1.0"] -# -# [tool.uv] -# extra-index-url = ["https://forge.nasty.sh/api/packages/lilith/pypi/simple/"] +# dependencies = ["lilith-gdscript-transpiler @ file:///var/home/lilith/Code/@packages/@py/gdscript-transpiler"] # /// +# TODO: publish 1.1.1 to forge.nasty.sh, then switch back to: +# dependencies = ["lilith-gdscript-transpiler>=1.1.1"] +# [tool.uv] extra-index-url = ["https://forge.nasty.sh/api/packages/lilith/pypi/simple/"] """ Magic Civilization engine transpiler — thin CLI. @@ -36,6 +36,7 @@ from lilith_gdscript_transpiler import ( ) from mapgen_assembly import _mg_build_full_output from ecology_assembly import _eco_build_full_output +from atmosphere_assembly import _atmo_build_methods, _atmo_process_calls REPO = Path(__file__).resolve().parent.parent.parent @@ -49,25 +50,28 @@ SOURCES = { "ecological_events": REPO / "engine/src/modules/climate/ecological_events.gd", "anchor_decay": REPO / "engine/src/modules/climate/anchor_decay.gd", "climate_spec_eval": REPO / "engine/src/modules/climate/climate_spec_eval.gd", + # TODO: Add atmosphere.gd + atmosphere_anomalies.gd once assembly template + # includes stepBaselinePressure → stepAnomalies → stepWindFromPressure → stepHumidity + # before stepTemperature. This will make wind_speed dynamic per turn. } OUTPUT = REPO / "packages/engine-ts/src/ClimatePhysics.generated.ts" REQUIRED_GD_FUNCTIONS: list[tuple[str, str]] = [ - ("climate_base", "_update_temperatures"), - ("climate_base", "_update_lake_thermal_effects"), - ("climate_base", "_update_moisture_wind"), - ("climate", "_update_moisture_rivers"), - ("climate", "_update_lake_evaporation"), - ("climate", "_update_deep_earth_water"), - ("climate", "_update_precipitation"), - ("climate_base", "_check_terrain_evolution"), - ("climate", "_compute_global_stats"), - ("climate", "_clear_magic_deltas"), - ("climate_spec_eval", "ideal_terrain"), - ("climate_spec_eval", "ley_channeling_mult"), - ("ecological_events", "process_events"), - ("anchor_decay", "process_decay"), + ("climate_base", "_update_temperatures"), + ("climate_base", "_update_lake_thermal_effects"), + ("climate_base", "_update_moisture_wind"), + ("climate", "_update_moisture_rivers"), + ("climate", "_update_lake_evaporation"), + ("climate", "_update_deep_earth_water"), + ("climate", "_update_precipitation"), + ("climate_base", "_check_terrain_evolution"), + ("climate", "_compute_global_stats"), + ("climate", "_clear_magic_deltas"), + ("climate_spec_eval", "ideal_terrain"), + ("climate_spec_eval", "ley_channeling_mult"), + ("ecological_events", "process_events"), + ("anchor_decay", "process_decay"), ] # --------------------------------------------------------------------------- @@ -348,6 +352,11 @@ export class ClimatePhysics { this.stepCollectMagicForcing(grid) this.stepOrbitalForcing(grid, turn) this.stepAerosolForcing(grid) + // Atmosphere: pressure → anomalies → wind → humidity (before temperature) + this.stepBaselinePressure(grid) + this.stepAnomalies(grid, turn) + this.stepWindFromPressure(grid) + this.stepHumidity(grid) this.stepTemperature(grid) this.stepLakeThermal(grid) this.stepMoistureWind(grid) @@ -933,6 +942,9 @@ def assemble(fns: dict[str, dict[str, str]]) -> str: parts.append(_emit_ecological_events()) parts.append("\n") parts.append(_emit_anchor_decay()) + parts.append("\n") + # Atmosphere methods (pressure, anomalies, wind, humidity) + parts.append(_atmo_build_methods()) parts.append("\n}\n") result = "".join(parts) @@ -1032,34 +1044,31 @@ mapgen_config = TranspilerConfig( # =========================================================================== ECOLOGY_SOURCES = { - "flora": REPO / "engine/src/modules/ecology/flora.gd", - "fauna": REPO / "engine/src/modules/ecology/fauna.gd", - "ecosystem": REPO / "engine/src/modules/ecology/ecosystem.gd", + "flora": REPO / "engine/src/modules/ecology/flora.gd", + "fauna_simplified": REPO / "engine/src/modules/ecology/fauna_simplified.gd", + "ecosystem_simplified": REPO / "engine/src/modules/ecology/ecosystem_simplified.gd", } ECOLOGY_OUTPUT = REPO / "packages/engine-ts/src/EcologyPhysics.generated.ts" REQUIRED_ECOLOGY_FUNCTIONS: list[tuple[str, str]] = [ - ("flora", "process_turn"), - ("flora", "_tick_canopy"), - ("flora", "_tick_undergrowth"), - ("flora", "_tick_fungi"), - ("flora", "_tick_succession"), - ("flora", "_tick_desertification"), - ("flora", "_tick_regrowth"), - ("ecosystem", "process_turn"), - ("ecosystem", "_compute_tile_quality"), - ("ecosystem", "_compute_global_health"), + ("flora", "tick_canopy"), + ("flora", "tick_undergrowth"), + ("flora", "tick_fungi"), + ("flora", "tick_succession"), + ("flora", "tick_desertification"), + ("flora", "tick_regrowth"), + # fauna_simplified.gd and ecosystem_simplified.gd may not exist yet — + # ecology_assembly.py handles missing files with fallback hand-written sections. ] def assemble_ecology(fns: dict[str, dict[str, str]]) -> str: """Assemble EcologyPhysics.generated.ts from ecology GDScript. - The ecology system uses Dictionary-keyed tile maps, DataLoader, EventBus, - and SQLite — none of which exist in TypeScript. All sections are hand-written - TypeScript that faithfully implements the GDScript ecology pipeline on flat - TileState[] arrays. + Flora tick functions are auto-transpiled from flora.gd. + Fauna and ecosystem sections are auto-transpiled from *_simplified.gd when those + files exist, or fall back to hand-written TypeScript equivalents. """ return _eco_build_full_output()