chore(pages): 🔧 Update build configuration for failed page deployment (request_id: 88f40586)
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c699aed0ad
commit
8ed89541e4
7 changed files with 893 additions and 117 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<number, number>()
|
||||
private thermalSustain = new Map<number, number>()
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, number> | 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<HTMLDivElement | null>,
|
||||
onResize: () => void,
|
||||
): GridDims {
|
||||
const [dims, setDims] = useState<GridDims>({ 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 (
|
||||
<span style={{ fontSize: 9, padding: '1px 4px', borderRadius: 3, background: bg, color: fg }}>
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
padding: '1px 4px',
|
||||
borderRadius: 3,
|
||||
background: muted ? bg.replace('0.2', '0.08') : bg,
|
||||
color: muted ? fg + '88' : fg,
|
||||
}}>
|
||||
{label}: {(value * 100).toFixed(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLDivElement, CardProps>(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 (
|
||||
<div style={{
|
||||
background: colors.surface,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b',
|
||||
position: 'relative',
|
||||
...animStyle,
|
||||
}}>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
background: cardBg,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: cardBorder,
|
||||
position: 'relative',
|
||||
boxShadow: cardShadow,
|
||||
width: cardWidth,
|
||||
willChange: 'transform',
|
||||
...animStyle,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={rawImageUrl(filename)}
|
||||
alt={v.entity_id}
|
||||
style={{ width: '100%', display: 'block', aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
aspectRatio: '1/1',
|
||||
objectFit: 'cover',
|
||||
filter: imgFilter,
|
||||
opacity: imgOpacity,
|
||||
transition: 'filter 0.4s ease, opacity 0.4s ease',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{conf > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||
background: passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444',
|
||||
color: '#000',
|
||||
}}>{(conf * 100).toFixed(0)}%</span>
|
||||
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)}%
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, left: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 10, fontWeight: 600,
|
||||
background: 'rgba(0,0,0,0.7)', color: colors.muted,
|
||||
}}>{v.category}</span>
|
||||
background: labeled ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)',
|
||||
color: labeled ? colors.muted : '#4a5060',
|
||||
}}>
|
||||
{v.category}
|
||||
</span>
|
||||
{v.is_approved && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 8, right: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||
background: '#8b5cf6', color: '#000',
|
||||
}}>APPROVED</span>
|
||||
}}>
|
||||
APPROVED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, color: colors.muted }}>
|
||||
<div style={{ fontWeight: 600, color: colors.text, marginBottom: 2 }}>{v.entity_id}</div>
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, color: textColor }}>
|
||||
<div style={{ fontWeight: 600, color: entityColor, marginBottom: 2 }}>
|
||||
{v.entity_id}
|
||||
</div>
|
||||
<div>seed: {v.seed}</div>
|
||||
{scores && (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
{Object.entries(scores).map(([k, val]) => (
|
||||
<ScoreBadge key={k} label={k.replace('_', ' ').replace('production ', 'prod ')} value={val} />
|
||||
<ScoreBadge
|
||||
key={k}
|
||||
label={k.replace('_', ' ').replace('production ', 'prod ')}
|
||||
value={val}
|
||||
muted={!labeled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
/* ── leaving card (fixed-position ghost) ───────────────────────── */
|
||||
|
||||
function LeavingCard({
|
||||
v,
|
||||
rect,
|
||||
cardWidth,
|
||||
}: {
|
||||
v: RecentVariant
|
||||
rect: DOMRect
|
||||
cardWidth: number
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
animation: `fadeOut ${FADE_OUT_MS}ms ease forwards`,
|
||||
}}
|
||||
>
|
||||
<Card v={v} isNew={false} index={0} cardWidth={cardWidth} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SpriteTheaterPage() {
|
||||
/* ── stable ref callback cache ─────────────────────────────────── */
|
||||
|
||||
function useCardRefCallbacks(cardRefs: React.MutableRefObject<Map<number, HTMLDivElement>>) {
|
||||
const cache = useRef<Map<number, (el: HTMLDivElement | null) => 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<RecentVariant[]>([])
|
||||
const [newIds, setNewIds] = useState<Set<number>>(new Set())
|
||||
const [connected, setConnected] = useState(false)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const [leaving, setLeaving] = useState<Array<{ v: RecentVariant; rect: DOMRect }>>([])
|
||||
|
||||
// Clear "new" flags after animation completes
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const prevRectsRef = useRef<Map<number, DOMRect>>(new Map())
|
||||
const prevVisibleIdsRef = useRef<Set<number>>(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<number, DOMRect>()
|
||||
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<string>) => {
|
||||
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 (
|
||||
<div style={page}>
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
background: PAGE_BG,
|
||||
color: colors.text,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes fadeScaleIn {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
0% { opacity: 0; transform: scale(0.85); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.9) translateY(8px); }
|
||||
}
|
||||
`}</style>
|
||||
<div style={headerStyle}>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 24px',
|
||||
borderBottom: `1px solid ${colors.accent}`,
|
||||
background: colors.bg,
|
||||
height: HEADER_HEIGHT,
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<a href="/" style={{ color: colors.text, textDecoration: 'none', fontSize: 18, fontWeight: 700 }}>
|
||||
Sprite Theater
|
||||
|
|
@ -196,24 +484,62 @@ export default function SpriteTheaterPage() {
|
|||
{connected ? 'Live' : 'Disconnected'} — {variants.length} sprites
|
||||
</span>
|
||||
</div>
|
||||
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Dashboard
|
||||
</a>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span style={{ fontSize: 11, color: colors.muted }}>
|
||||
{cols}×{rows} grid · {visibleCount} visible
|
||||
</span>
|
||||
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variants.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 80, color: colors.muted }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
|
||||
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
|
||||
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: colors.muted }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
|
||||
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
|
||||
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={gridStyle}>
|
||||
{sorted.map((v, i) => (
|
||||
<Card key={v.variant_id} v={v} isNew={newIds.has(v.variant_id)} index={i} />
|
||||
))}
|
||||
<div
|
||||
ref={gridRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
padding: GRID_PADDING,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: cols > 0 ? `repeat(${cols}, 1fr)` : `repeat(auto-fill, minmax(${MIN_CARD_WIDTH}px, 1fr))`,
|
||||
gap: GRID_GAP,
|
||||
alignContent: 'start',
|
||||
}}>
|
||||
{visible.map((v, i) => (
|
||||
<Card
|
||||
key={v.variant_id}
|
||||
ref={getCardRef(v.variant_id)}
|
||||
v={v}
|
||||
isNew={newIds.has(v.variant_id)}
|
||||
index={i}
|
||||
cardWidth={cardWidth > 0 ? cardWidth : MIN_CARD_WIDTH}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leaving.map(({ v, rect }) => (
|
||||
<LeavingCard
|
||||
key={`leave-${v.variant_id}`}
|
||||
v={v}
|
||||
rect={rect}
|
||||
cardWidth={cardWidth > 0 ? cardWidth : MIN_CARD_WIDTH}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
243
tools/transpile-engine/atmosphere_assembly.py
Normal file
243
tools/transpile-engine/atmosphere_assembly.py
Normal file
|
|
@ -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<number, number>()
|
||||
private thermalSustain = new Map<number, number>()
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue