From 86cd67889b8c4ace8ecf6d3a52b9552322895cbe Mon Sep 17 00:00:00 2001 From: Ael Date: Wed, 22 Jul 2026 00:45:30 +0200 Subject: [PATCH 1/2] feat: add selectable water cell records --- .../realm/RealmAccessibilityControls.tsx | 45 ++++- src/components/realm/RealmMapScreen.css | 33 ++++ src/components/realm/RealmMapScreen.tsx | 95 +++++++++- src/components/realm/WaterInspectionPanel.css | 1 + src/components/realm/WaterInspectionPanel.tsx | 150 ++++++++++++++++ src/components/realm/createRealmScene.ts | 92 +++++++++- src/components/realm/realmInteractionState.ts | 53 +++++- src/components/realm/realmPickArbitration.ts | 27 +++ .../realm/realmWaterInspectionPresentation.ts | 170 ++++++++++++++++++ src/components/realm/realmWaterLayer.ts | 116 ++++++++++++ tests/realmInteractionState.test.ts | 28 +++ tests/realmPickArbitration.test.ts | 41 +++++ .../realmWaterInspectionPresentation.test.ts | 59 ++++++ tests/realmWaterLayer.test.ts | 37 +++- 14 files changed, 934 insertions(+), 13 deletions(-) create mode 100644 src/components/realm/WaterInspectionPanel.css create mode 100644 src/components/realm/WaterInspectionPanel.tsx create mode 100644 src/components/realm/realmWaterInspectionPresentation.ts create mode 100644 tests/realmWaterInspectionPresentation.test.ts diff --git a/src/components/realm/RealmAccessibilityControls.tsx b/src/components/realm/RealmAccessibilityControls.tsx index 5520c0e5..69c2d433 100644 --- a/src/components/realm/RealmAccessibilityControls.tsx +++ b/src/components/realm/RealmAccessibilityControls.tsx @@ -25,6 +25,15 @@ export type RealmNavigatorCastle = Readonly<{ r: number; }>; +export type RealmNavigatorWaterBody = Readonly<{ + bodyId: string; + label: string; + sourceCellKey: string; + mouthCellKey: string; + sourceCoord: HexCoord; + mouthCoord: HexCoord; +}>; + export type RealmNavigatorCloseReason = 'escape' | 'close-button' | 'camera-preset'; export type RealmNavigatorCoordinateJump = Readonly<{ @@ -43,11 +52,13 @@ export type RealmAccessibilityControlsProps = Readonly<{ id: string; open: boolean; castles: readonly RealmNavigatorCastle[]; + waterBodies?: readonly RealmNavigatorWaterBody[]; ownCastleId?: number; selectedCastleId?: number; onRequestOpen: () => void; onRequestClose: (reason: RealmNavigatorCloseReason) => void; onActivateCastle: (castle: RealmNavigatorCastle) => void; + onActivateWaterCell?: (cellKey: string) => void; coordinateJump?: RealmNavigatorCoordinateJump; cameraPresets?: readonly RealmNavigatorCameraPreset[]; /** Player chrome may provide its own PFP launcher while reusing this dialog. */ @@ -75,11 +86,13 @@ export function RealmAccessibilityControls({ id, open, castles, + waterBodies = [], ownCastleId, selectedCastleId, onRequestOpen, onRequestClose, onActivateCastle, + onActivateWaterCell, coordinateJump, cameraPresets = [], triggerVisible = true, @@ -126,6 +139,16 @@ export function RealmAccessibilityControls({ ? castles.filter((castle) => searchCopy(castle).includes(query)) : castles; }, [castles, search]); + const visibleWaterBodies = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + return query + ? waterBodies.filter((body) => ( + `${body.label} ${body.sourceCoord.q},${body.sourceCoord.r} ${body.mouthCoord.q},${body.mouthCoord.r}` + .toLocaleLowerCase() + .includes(query) + )) + : waterBodies; + }, [search, waterBodies]); const handleDialogKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; @@ -164,7 +187,7 @@ export function RealmAccessibilityControls({ + + + + + ))} + + + ) : null} + {coordinateJump ? (
diff --git a/src/components/realm/RealmMapScreen.css b/src/components/realm/RealmMapScreen.css index 1071cb6c..712fa784 100644 --- a/src/components/realm/RealmMapScreen.css +++ b/src/components/realm/RealmMapScreen.css @@ -499,6 +499,39 @@ font-weight: 800; } +.realm-cell-navigator__water { + display: grid; + gap: 0.35rem; + margin-top: 0.8rem; +} + +.realm-cell-navigator__water > span { + color: var(--realm-muted); + font-size: 0.6875rem; + font-weight: 800; + letter-spacing: 0.12em; +} + +.realm-cell-navigator__water-row { + display: grid; + gap: 0.28rem; + padding: 0.58rem 0.65rem; + border: 1px solid rgb(145 217 216 / 22%); + border-radius: 0.35rem; + background: rgb(19 59 70 / 24%); +} + +.realm-cell-navigator__water-row > div { + display: flex; + gap: 0.35rem; +} + +.realm-cell-navigator__water-row > div button { + width: auto; + padding: 0.28rem 0.45rem; + font-size: 0.625rem; +} + .realm-cell-navigator__community { display: grid; gap: 0.22rem; diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index a564a377..6c1f333b 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -38,6 +38,7 @@ import { FoodFarmInspectionPanel } from './FoodFarmInspectionPanel'; import { GoldMineInspectionPanel } from './GoldMineInspectionPanel'; import { LoggingCampInspectionPanel } from './LoggingCampInspectionPanel'; import { StoneQuarryInspectionPanel } from './StoneQuarryInspectionPanel'; +import { WaterInspectionPanel } from './WaterInspectionPanel'; import { RealmAccessibilityControls } from './RealmAccessibilityControls'; import { RealmCastleLabels, @@ -54,6 +55,11 @@ import { } from './createRealmScene'; import { resolveCanonicalWaterProjection } from './realmWaterProjection'; import { projectRealmWaterRevisionTerrainMetadata } from './realmWaterTerrainProjection'; +import { + realmWaterNavigatorBodies, + resolveRealmWaterInspectionRecords, + type RealmWaterInspectionRecord +} from './realmWaterInspectionPresentation'; import { resolveRealmGoldNodePresentations, type RealmGoldNodePresentation @@ -342,6 +348,20 @@ function CanonicalRealmMapScreen({ () => projectRealmWaterRevisionTerrainMetadata(sharedTileMetadata, waterCells), [sharedTileMetadata, waterCells] ); + const waterRecords = useMemo( + () => resolveRealmWaterInspectionRecords(waterCells, projectedTileMetadata), + [projectedTileMetadata, waterCells] + ); + const waterRecordsByKey = useMemo( + () => new Map(waterRecords.map((record) => [record.cellKey, record] as const)), + [waterRecords] + ); + const waterRecordsByKeyRef = useRef>(waterRecordsByKey); + waterRecordsByKeyRef.current = waterRecordsByKey; + const navigatorWaterBodies = useMemo( + () => realmWaterNavigatorBodies(waterRecords), + [waterRecords] + ); const otherCastles = snapshot.castles; const surface = useMemo( () => createAuthoritativeRealmTerrainSurface( @@ -694,10 +714,15 @@ function CanonicalRealmMapScreen({ && 'stoneSiteId' in selectedInspectorTarget ? stoneNodesBySiteId.get(selectedInspectorTarget.stoneSiteId) : undefined; + const inspectorWater = selectedInspectorTarget !== null + && 'cellKey' in selectedInspectorTarget + ? waterRecordsByKey.get(selectedInspectorTarget.cellKey) + : undefined; const goldNodeAtSelectedCell = goldNodes.find((node) => sameCoord(node.coord, selectedCoord)); const foodNodeAtSelectedCell = foodNodes.find((node) => sameCoord(node.coord, selectedCoord)); const woodNodeAtSelectedCell = woodNodes.find((node) => sameCoord(node.coord, selectedCoord)); const stoneNodeAtSelectedCell = stoneNodes.find((node) => sameCoord(node.coord, selectedCoord)); + const waterAtSelectedCell = waterRecords.find((record) => sameCoord(record.coord, selectedCoord)); const ownProfile = profileRecords.get(ownCastle.castleId)?.profile; const focusedCastleId = interaction.cameraTarget.kind === 'castle' ? interaction.cameraTarget.castleId @@ -768,6 +793,8 @@ function CanonicalRealmMapScreen({ inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'stone-quarry-inspector') { inspectorFocusRef.current?.focus({ preventScroll: true }); + } else if (target.kind === 'water-inspector') { + inspectorFocusRef.current?.focus({ preventScroll: true }); } else if (target.kind === 'castle-label') { const label = rootRef.current ?.querySelector(`.realm-castle-label[data-castle-id="${target.castleId}"]`) @@ -870,6 +897,19 @@ function CanonicalRealmMapScreen({ if (focusSite) sceneRef.current?.focusCell(node.coord); }, []); + const selectWaterCell = useCallback((record: RealmWaterInspectionRecord, focusWater = true) => { + selectedCoordRef.current = { ...record.coord }; + dispatchInteraction({ + type: 'activate-water-cell', + cellKey: record.cellKey, + bodyId: record.bodyId, + regime: record.regime, + coord: record.coord, + cameraIntent: focusWater ? 'focus-water' : 'preserve' + }); + if (focusWater) sceneRef.current?.focusWaterCell?.(record.cellKey); + }, []); + const openActiveWagon = useCallback((wagon: RealmActiveWagonMenuItem) => { if (!activeWagons.some((candidate) => ( candidate.resource === wagon.resource && candidate.siteId === wagon.siteId @@ -985,6 +1025,14 @@ function CanonicalRealmMapScreen({ // The scene reserves its restrained ground outline for unoccupied terrain; // castle identity and raycasting provide the occupied-cell cue without a // depth-tested line cutting through the wider authored landscape base. + if (target?.kind === 'water-cell') { + updateHoveredCastleId(undefined); + hoveredCoordRef.current = null; + sceneRef.current?.setHoveredWaterCellKey?.(target.cellKey); + sceneRef.current?.setHovered(null); + return; + } + sceneRef.current?.setHoveredWaterCellKey?.(null); updateHoveredCoord(target?.coord ?? null); updateHoveredCastleId(target?.kind === 'castle' ? target.castleId : undefined); }, [updateHoveredCastleId, updateHoveredCoord]); @@ -1016,8 +1064,13 @@ function CanonicalRealmMapScreen({ if (node) selectStoneNode(node, target.source !== 'wagon'); return; } + if (target.kind === 'water-cell') { + const record = waterRecordsByKeyRef.current.get(target.cellKey); + if (record) selectWaterCell(record); + return; + } selectCoord(target.coord); - }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWoodNode]); + }, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWaterCell, selectWoodNode]); const updateCastleProjection = useCallback((frame: RealmCastleProjectionFrame) => { latestProjectionRef.current = frame; @@ -1226,7 +1279,7 @@ function CanonicalRealmMapScreen({ root.querySelectorAll( '.realm-hud, .realm-hud__actions, .realm-profile-trigger, .realm-resource-rail, ' + '.castle-inspection, .gold-mine-inspection, .food-farm-inspection, .logging-camp-inspection, ' - + '.stone-quarry-inspection, .realm-cell-navigator' + + '.stone-quarry-inspection, .realm-cell-navigator, .water-inspection' ).forEach((element) => observer?.observe(element)); } window.addEventListener('resize', updateSceneComposition, { passive: true }); @@ -1458,10 +1511,18 @@ function CanonicalRealmMapScreen({ ? interactionRef.current.inspectorTarget.stoneSiteId : null ); + scene.setSelectedWaterCellKey?.( + interactionRef.current.inspectorOpen + && interactionRef.current.inspectorTarget !== null + && 'cellKey' in interactionRef.current.inspectorTarget + ? interactionRef.current.inspectorTarget.cellKey + : null + ); scene.setHovered(hoveredCoordRef.current); const cameraTarget: RealmCameraTarget = interactionRef.current.cameraTarget; if (cameraTarget.kind === 'castle') scene.focusCastle(cameraTarget.castleId); else if (cameraTarget.kind === 'cell') scene.focusCell(cameraTarget.coord); + else if (cameraTarget.kind === 'water') scene.focusWaterCell?.(cameraTarget.cellKey); else if (cameraTarget.kind === 'keep') scene.recenterKeep(); else if (cameraTarget.kind === 'founding-district') scene.frameFoundingDistrict(); else scene.showRealm(); @@ -1518,6 +1579,10 @@ function CanonicalRealmMapScreen({ sceneRef.current?.setSelectedStoneSiteId?.(inspectorStoneNode?.siteId ?? null); }, [inspectorStoneNode?.siteId]); + useEffect(() => { + sceneRef.current?.setSelectedWaterCellKey?.(inspectorWater?.cellKey ?? null); + }, [inspectorWater?.cellKey]); + useEffect(() => { const handleEscape = (event: KeyboardEvent) => { // Treat one physical Escape press as one hierarchy step. Ignoring the @@ -1600,6 +1665,8 @@ function CanonicalRealmMapScreen({ selectWoodNode(woodNodeAtSelectedCell); } else if (stoneNodeAtSelectedCell) { selectStoneNode(stoneNodeAtSelectedCell); + } else if (waterAtSelectedCell) { + selectWaterCell(waterAtSelectedCell); } else { sceneRef.current?.focusCell(selectedCoord); dispatchInteraction({ @@ -2047,10 +2114,27 @@ function CanonicalRealmMapScreen({ /> ) : null} + {inspectorWater ? ( + dispatchInteraction({ type: 'close-inspector' })} + onFocusCell={(cellKey) => { + const record = waterRecordsByKeyRef.current.get(cellKey); + if (record) selectWaterCell(record); + }} + onViewUnderlyingCell={inspectorWater.underlyingTileKey + ? () => selectCoord(inspectorWater.coord) + : undefined} + /> + ) : null} + candidate.castleId === entry.castleId); if (castle) selectCastle(castle); }} + onActivateWaterCell={(cellKey) => { + const record = waterRecordsByKeyRef.current.get(cellKey); + if (record) { + selectWaterCell(record); + dispatchInteraction({ type: 'close-navigator' }); + } + }} coordinateJump={{ validate: (coord) => ( isPlayableRealmCoord(surface, coord) diff --git a/src/components/realm/WaterInspectionPanel.css b/src/components/realm/WaterInspectionPanel.css new file mode 100644 index 00000000..0a48d8eb --- /dev/null +++ b/src/components/realm/WaterInspectionPanel.css @@ -0,0 +1 @@ +.water-inspection{position:fixed;z-index:14;inset:0 0 0 auto;width:min(25rem,100vw);overflow:hidden;color:#eef7f3;background:radial-gradient(circle at 50% 15%,rgb(68 170 191 / 24%),transparent 44%),linear-gradient(180deg,#0b1a22,#071015 74%);box-shadow:-1rem 0 3rem rgb(0 0 0 / 42%);font-family:Inter,ui-sans-serif,system-ui,sans-serif}.water-inspection__drawer{display:flex;height:100%;flex-direction:column}.water-inspection__hero{position:relative;min-height:15.8rem;padding:10.5rem 1rem .8rem;background:linear-gradient(180deg,rgb(9 28 38 / 10%),rgb(7 16 21 / 97%) 88%)}.water-inspection__hero-art-stage{position:absolute;z-index:0;top:-1.8rem;left:50%;width:min(29rem,126%);height:16rem;transform:translateX(-50%);pointer-events:none}.water-inspection__art{position:relative;width:100%;height:100%;overflow:hidden;background:radial-gradient(circle at 50% 50%,rgb(190 244 238 / 21%),transparent 48%),linear-gradient(180deg,#2a5662,#0b1a23 68%);filter:drop-shadow(0 .4rem .2rem rgb(0 0 0 / 36%)) drop-shadow(0 1rem 1.3rem rgb(0 0 0 / 52%));clip-path:polygon(2% 4%,98% 4%,100% 86%,80% 100%,20% 100%,0 86%)}.water-inspection__art--river{background:radial-gradient(ellipse at 53% 39%,rgb(246 226 157 / 33%),transparent 23%),linear-gradient(180deg,#6baab2,#1b4b62 55%,#0a1d29)}.water-inspection__art-sun{position:absolute;top:2.3rem;left:54%;width:4rem;height:4rem;border-radius:50%;background:#f6df96;box-shadow:0 0 2.8rem 1rem rgb(247 222 148 / 42%)}.water-inspection__art-horizon{position:absolute;right:0;bottom:4.6rem;left:0;height:3rem;background:linear-gradient(180deg,transparent,rgb(11 38 46 / 60%))}.water-inspection__art-wave{position:absolute;left:-10%;width:120%;height:3.2rem;border:2px solid rgb(173 237 231 / 54%);border-color:rgb(173 237 231 / 54%) transparent transparent;border-radius:50%;transform:rotate(-7deg)}.water-inspection__art-wave--one{bottom:3.4rem}.water-inspection__art-wave--two{bottom:1rem;transform:rotate(5deg);opacity:.72}.water-inspection__art-crest{position:absolute;right:16%;bottom:3.2rem;color:#d7f3e9;font:500 5.4rem/1 Georgia,serif;text-shadow:0 0 1rem rgb(177 255 242 / 68%);transform:rotate(-8deg)}.water-inspection__title-lockup{position:relative;z-index:1;padding:0 2.8rem;text-align:center}.water-inspection__title-lockup p{margin:0;color:#b8e7dd;font-size:.62rem;font-weight:800;letter-spacing:.15em}.water-inspection__title-lockup h2{margin:.24rem 0 0;color:#e6d79b;font:500 1.88rem/1.05 Georgia,serif;letter-spacing:.055em}.water-inspection__dismiss{position:absolute;z-index:2;top:.68rem;right:.7rem;display:grid;width:2.8rem;height:2.8rem;place-items:center;border:1px solid rgb(215 240 231 / 42%);border-radius:50%;background:rgb(5 16 21 / 82%);color:#f4f7e8;font:400 1.8rem/1 Georgia,serif;cursor:pointer}.water-inspection__dismiss:focus-visible,.water-inspection__actions button:focus-visible{outline:2px solid #d7f1c5;outline-offset:2px}.water-inspection__body{flex:1;min-height:0;padding:.72rem 1rem 1.15rem;overflow:auto}.water-inspection__description,.water-inspection__read-only{margin:0;padding:.72rem .78rem;border-left:2px solid rgb(113 212 206 / 66%);background:linear-gradient(90deg,rgb(35 119 133 / 26%),transparent);color:#cbe4df;font-size:.76rem;line-height:1.5}.water-inspection__fields{display:grid;margin:.9rem 0 0;border:1px solid rgb(215 240 231 / 23%);border-radius:.56rem;overflow:hidden}.water-inspection__field{display:grid;grid-template-columns:minmax(7.1rem,.82fr) minmax(0,1.18fr);gap:.75rem;padding:.66rem .74rem;border-bottom:1px solid rgb(215 240 231 / 12%)}.water-inspection__field:last-child{border-bottom:0}.water-inspection__field dt,.water-inspection__field dd{margin:0}.water-inspection__field dt{color:#aec3c3;font-size:.72rem}.water-inspection__field dd{color:#e8d99e;font-size:.73rem;font-weight:760;text-align:right;overflow-wrap:anywhere}.water-inspection__read-only{margin-top:.82rem;border-left-color:rgb(212 228 255 / 56%)}.water-inspection__actions{display:grid;gap:.52rem;margin-top:.9rem}.water-inspection__actions button{min-height:2.65rem;padding:.56rem .7rem;border:1px solid rgb(214 239 216 / 62%);border-radius:.44rem;background:linear-gradient(180deg,rgb(85 170 173 / 97%),rgb(27 75 89 / 97%));color:#f0f6e6;font-size:.66rem;font-weight:840;letter-spacing:.1em;cursor:pointer}@media (max-width:40rem){.water-inspection{width:100%}.water-inspection__hero{min-height:14rem;padding-top:9.4rem}} diff --git a/src/components/realm/WaterInspectionPanel.tsx b/src/components/realm/WaterInspectionPanel.tsx new file mode 100644 index 00000000..dfd4af18 --- /dev/null +++ b/src/components/realm/WaterInspectionPanel.tsx @@ -0,0 +1,150 @@ +import { + useCallback, + useEffect, + useRef, + type Ref +} from 'react'; + +import type { RealmWaterInspectionRecord } from './realmWaterInspectionPresentation'; +import './WaterInspectionPanel.css'; + +export type WaterInspectionPanelProps = Readonly<{ + id: string; + record: RealmWaterInspectionRecord; + focusTargetRef?: Ref; + onRequestClose: () => void; + onFocusCell?: (cellKey: string) => void; + onViewUnderlyingCell?: () => void; +}>; + +function assignRef(ref: Ref | undefined, value: T | null) { + if (typeof ref === 'function') ref(value); + else if (ref) (ref as { current: T | null }).current = value; +} + +function WaterRecordArt({ record }: Readonly<{ record: RealmWaterInspectionRecord }>) { + return ( + + ); +} + +function PublicField({ label, value }: Readonly<{ label: string; value: string }>) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function WaterInspectionPanel({ + id, + record, + focusTargetRef, + onRequestClose, + onFocusCell, + onViewUnderlyingCell +}: WaterInspectionPanelProps) { + const closeButtonRef = useRef(null); + const titleId = `${id}-title`; + const descriptionId = `${id}-description`; + const setCloseButtonRef = useCallback((element: HTMLButtonElement | null) => { + closeButtonRef.current = element; + assignRef(focusTargetRef, element); + }, [focusTargetRef]); + + useEffect(() => { + closeButtonRef.current?.focus({ preventScroll: true }); + }, [id, record.cellKey]); + + const eyebrow = record.regime === 'river' + ? 'RIVER' + : record.displayType === 'coast' ? 'COAST' : 'OUTER SEA'; + const position = record.riverPosition + ? `${record.riverPosition} · ${record.flowClass}` + : `${record.oceanDepthClass} · ${record.fogBand} view`; + + return ( + + ); +} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index ca10ebf0..f45dcbb7 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -90,6 +90,7 @@ import { import { createRealmAmbientScheduler, type RealmAmbientScheduler } from './realmAmbientScheduler'; import { createRealmWaterLayer, + waterSurfaceLevelToWorldY, type RealmWaterLayer } from './realmWaterLayer'; import { @@ -135,7 +136,8 @@ import { import { arbitrateRealmPick, type RealmInteractionTarget, - type RealmResourcePickHit + type RealmResourcePickHit, + type RealmWaterPickHit } from './realmPickArbitration'; import { REALM_LIGHTING_SPECS, @@ -421,6 +423,7 @@ export type RealmSceneHandle = Readonly<{ getSceneBuildSequence: () => number; focusCastle: (castleId: number) => void; focusCell: (coord: HexCoord) => void; + focusWaterCell: (cellKey: string) => void; frameFoundingDistrict: () => void; focusKeep: () => void; recenterKeep: () => void; @@ -432,6 +435,8 @@ export type RealmSceneHandle = Readonly<{ setSelectedFoodSiteId: (siteId: string | null) => void; setSelectedWoodSiteId: (siteId: string | null) => void; setSelectedStoneSiteId: (siteId: string | null) => void; + setSelectedWaterCellKey: (cellKey: string | null) => void; + setHoveredWaterCellKey: (cellKey: string | null) => void; setComposition: (composition: RealmCameraComposition) => void; showRealm: () => void; }>; @@ -1246,6 +1251,9 @@ function initializeRealmScene( scene.add(terrain); let waterLayer: RealmWaterLayer | null = null; + const waterCellByKey = new Map( + (options.waterCells ?? []).map((cell) => [cell.cellKey, cell] as const) + ); if (options.waterCells !== undefined) { try { waterLayer = createRealmWaterLayer({ @@ -1612,6 +1620,11 @@ function initializeRealmScene( const stoneNodeCoordinateKeys = new Set( (options.stoneNodes ?? []).map((node) => hexKey(node.coord)) ); + const waterCellCoordinateKeys = new Set( + (options.waterCells ?? []) + .filter((cell) => cell.regime === 'ocean' || cell.regime === 'river') + .map((cell) => `${cell.q},${cell.r}`) + ); const terrainOverlayCoord = (coord: HexCoord | null) => ( coord && !occupiedCastleCoordinateKeys.has(hexKey(coord)) @@ -1619,6 +1632,7 @@ function initializeRealmScene( && !foodNodeCoordinateKeys.has(hexKey(coord)) && !woodNodeCoordinateKeys.has(hexKey(coord)) && !stoneNodeCoordinateKeys.has(hexKey(coord)) + && !waterCellCoordinateKeys.has(hexKey(coord)) ? coord : null ); @@ -2237,7 +2251,8 @@ function initializeRealmScene( const stoneNodeHit = stoneNodeLayer?.raycast(raycaster); if (stoneNodeHit) resourceHits.push({ kind: 'stone-site', ...stoneNodeHit }); const castleHit = castleLayer?.raycast(raycaster); - const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit }); + const waterHit: RealmWaterPickHit | null = waterLayer?.raycast(raycaster) ?? null; + const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit, waterHit }); if (foregroundHit) return foregroundHit; const intersections = raycaster.intersectObject(terrain, false); for (const intersection of intersections) { @@ -3028,6 +3043,28 @@ function initializeRealmScene( footprintDiameter: 1.24 }); }, + focusWaterCell: (cellKey) => { + if (cleanup.isDisposed()) return; + const cell = waterCellByKey.get(cellKey); + if (!cell || (cell.regime === 'ocean' && cell.fogBand === 'full')) return; + const world = axialToWorld({ q: cell.q, r: cell.r }, HEX_SIZE); + const terrainY = terrainHeightAtWorld( + options.surface.renderMap, + world, + HEX_SIZE, + terrainPlacements + ); + const surfaceY = cell.regime === 'river' + ? Math.max(waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + 0.035, terrainY + 0.04) + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + 0.035; + cameraController.focusAt({ + x: world.x, + y: surfaceY, + z: world.z, + height: 0.18, + footprintDiameter: 1.24 + }); + }, frameFoundingDistrict: () => { const width = Math.max(1, options.canvas.clientWidth || window.innerWidth || 1); const height = Math.max(1, options.canvas.clientHeight || window.innerHeight || 1); @@ -3041,6 +3078,7 @@ function initializeRealmScene( recenterKeep: cameraController.recenterKeep, setHovered: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(null); hoveredTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); // A terrain hex runs through the wider authored landscape-base mesh. @@ -3068,6 +3106,7 @@ function initializeRealmScene( }, setSelected: (coord) => { if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = coord; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); selectedCastleId = coord @@ -3087,6 +3126,7 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedCastleId = castleId === null ? undefined : castleId; if (castleId !== null) { + waterLayer?.setSelectedCellKey(null); selectedTerrainCoord = null; grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); setOverlay(selectedOverlay, options.surface, null, terrainPlacements); @@ -3097,28 +3137,68 @@ function initializeRealmScene( if (cleanup.isDisposed()) return; selectedGoldSiteId = siteId === null ? undefined : siteId; goldNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedFoodSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedFoodSiteId = siteId === null ? undefined : siteId; foodNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedWoodSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedWoodSiteId = siteId === null ? undefined : siteId; woodNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } render(); }, setSelectedStoneSiteId: (siteId) => { if (cleanup.isDisposed()) return; selectedStoneSiteId = siteId === null ? undefined : siteId; stoneNodeLayer?.setSelectedSiteId(siteId); - if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + if (siteId !== null) { + waterLayer?.setSelectedCellKey(null); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setSelectedWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setSelectedCellKey(cellKey); + if (cellKey !== null) { + selectedTerrainCoord = null; + selectedCastleId = undefined; + selectedGoldSiteId = undefined; + selectedFoodSiteId = undefined; + selectedWoodSiteId = undefined; + selectedStoneSiteId = undefined; + goldNodeLayer?.setSelectedSiteId(null); + foodNodeLayer?.setSelectedSiteId(null); + woodNodeLayer?.setSelectedSiteId(null); + stoneNodeLayer?.setSelectedSiteId(null); + grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord); + setOverlay(selectedOverlay, options.surface, null, terrainPlacements); + } + render(); + }, + setHoveredWaterCellKey: (cellKey) => { + if (cleanup.isDisposed()) return; + waterLayer?.setHoveredCellKey(cellKey); + if (cellKey !== null) { + hoveredTerrainCoord = null; + setOverlay(hoverOverlay, options.surface, null, terrainPlacements); + } render(); }, setComposition: (composition) => cameraController.setComposition(composition), diff --git a/src/components/realm/realmInteractionState.ts b/src/components/realm/realmInteractionState.ts index 2796d99f..db0d18ad 100644 --- a/src/components/realm/realmInteractionState.ts +++ b/src/components/realm/realmInteractionState.ts @@ -36,13 +36,21 @@ export type RealmStoneSiteTarget = Readonly<{ coord: HexCoord; }>; +export type RealmWaterCellTarget = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; +}>; + export type RealmInspectorTarget = | RealmWorkerTarget | RealmCastleTarget | RealmGoldSiteTarget | RealmFoodSiteTarget | RealmWoodSiteTarget - | RealmStoneSiteTarget; + | RealmStoneSiteTarget + | RealmWaterCellTarget; export type RealmCameraTarget = | Readonly<{ kind: 'realm' }> @@ -50,7 +58,10 @@ export type RealmCameraTarget = | Readonly<{ kind: 'keep' }> | Readonly<{ kind: 'cell'; coord: HexCoord }> | Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }> - | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }>; + | Readonly<{ kind: 'worker'; workerId: string; coord: HexCoord }> + // Water focus is bounded by the validated visible projection, not land + // passability; full-fog cells never become camera targets. + | Readonly<{ kind: 'water'; cellKey: string; coord: HexCoord }>; export type RealmKeyboardTarget = | Readonly<{ kind: 'map' }> @@ -60,6 +71,7 @@ export type RealmKeyboardTarget = | Readonly<{ kind: 'food-farm-inspector'; siteId: string }> | Readonly<{ kind: 'logging-camp-inspector'; siteId: string }> | Readonly<{ kind: 'stone-quarry-inspector'; siteId: string }> + | Readonly<{ kind: 'water-inspector'; cellKey: string }> | Readonly<{ kind: 'castle-label'; castleId: number }> | Readonly<{ kind: 'navigator' }> | Readonly<{ kind: 'navigator-trigger' }>; @@ -111,6 +123,14 @@ export type RealmInteractionAction = coord: HexCoord; cameraIntent?: 'focus-site' | 'preserve'; }> + | Readonly<{ + type: 'activate-water-cell'; + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; + cameraIntent?: 'focus-water' | 'preserve'; + }> | Readonly<{ type: 'close-inspector' }> | Readonly<{ type: 'recenter-keep'; coord: HexCoord }> | Readonly<{ type: 'set-camera-target'; target: RealmCameraTarget }> @@ -159,6 +179,15 @@ function copyStoneSiteTarget(target: RealmStoneSiteTarget): RealmStoneSiteTarget return { stoneSiteId: target.stoneSiteId, coord: copyCoord(target.coord) }; } +function copyWaterCellTarget(target: RealmWaterCellTarget): RealmWaterCellTarget { + return { + cellKey: target.cellKey, + bodyId: target.bodyId, + regime: target.regime, + coord: copyCoord(target.coord) + }; +} + function isCastleTarget(target: RealmInspectorTarget | null): target is RealmCastleTarget { return target !== null && 'castleId' in target; } @@ -169,6 +198,7 @@ function copyCameraTarget(target: RealmCameraTarget): RealmCameraTarget { if (target.kind === 'keep') return { kind: 'keep' }; if (target.kind === 'cell') return { kind: 'cell', coord: copyCoord(target.coord) }; if (target.kind === 'worker') return { kind: 'worker', workerId: target.workerId, coord: copyCoord(target.coord) }; + if (target.kind === 'water') return { kind: 'water', cellKey: target.cellKey, coord: copyCoord(target.coord) }; return { kind: 'castle', castleId: target.castleId, coord: copyCoord(target.coord) }; } @@ -315,6 +345,25 @@ export function realmInteractionReducer( }; } + case 'activate-water-cell': { + const target = copyWaterCellTarget(action); + return { + ...state, + selectedCell: copyCoord(target.coord), + selectedCastle: null, + inspectorTarget: target, + inspectorOpen: true, + cameraTarget: action.cameraIntent === 'preserve' + ? state.cameraTarget + : { kind: 'water', cellKey: target.cellKey, coord: copyCoord(target.coord) }, + navigatorOpen: false, + keyboardIntent: withKeyboardIntent(state, { + kind: 'water-inspector', + cellKey: target.cellKey + }) + }; + } + case 'close-inspector': { if (!state.inspectorOpen) return state; const castle = isCastleTarget(state.inspectorTarget) diff --git a/src/components/realm/realmPickArbitration.ts b/src/components/realm/realmPickArbitration.ts index 58e16abf..2deb03f6 100644 --- a/src/components/realm/realmPickArbitration.ts +++ b/src/components/realm/realmPickArbitration.ts @@ -20,6 +20,22 @@ export type RealmWorkerPickHit = Readonly<{ coord: HexCoord; distance: number; }>; +export type RealmWaterPickHit = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; + distance: number; +}>; + +/** A visible, public water identity that can be handed to the interaction lane. */ +export type RealmWaterInteractionTarget = Readonly<{ + kind: 'water-cell'; + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: HexCoord; +}>; export type RealmInteractionTarget = | Readonly<{ kind: 'worker'; workerId: string; workerOrdinal: number; originCastleId: number; coord: HexCoord }> @@ -30,6 +46,7 @@ export type RealmInteractionTarget = coord: HexCoord; source: 'site' | 'wagon'; }> + | RealmWaterInteractionTarget | Readonly<{ kind: 'terrain'; coord: HexCoord }>; function nearestValidHit( @@ -56,6 +73,7 @@ function nearestValidHit( export function arbitrateRealmPick(input: Readonly<{ resourceHits: readonly RealmResourcePickHit[]; workerHits?: readonly RealmWorkerPickHit[]; + waterHit?: RealmWaterPickHit | null; castleHit?: Readonly<{ castleId: number; coord: HexCoord }> | null; terrainHit?: Readonly<{ coord: HexCoord }> | null; }>): RealmInteractionTarget | null { @@ -94,6 +112,15 @@ export function arbitrateRealmPick(input: Readonly<{ source: site.source }); } + if (input.waterHit && Number.isFinite(input.waterHit.distance) && input.waterHit.distance >= 0) { + return Object.freeze({ + kind: 'water-cell', + cellKey: input.waterHit.cellKey, + bodyId: input.waterHit.bodyId, + regime: input.waterHit.regime, + coord: input.waterHit.coord + }); + } return input.terrainHit ? Object.freeze({ kind: 'terrain', coord: input.terrainHit.coord }) : null; diff --git a/src/components/realm/realmWaterInspectionPresentation.ts b/src/components/realm/realmWaterInspectionPresentation.ts new file mode 100644 index 00000000..937934c9 --- /dev/null +++ b/src/components/realm/realmWaterInspectionPresentation.ts @@ -0,0 +1,170 @@ +import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates'; +import { + GENESIS_WATER_BODIES_V1, + type GenesisWaterBodyV1, + type GenesisWaterCellV1 +} from '../../../spacetimedb/src/waterWorld'; +import type { RealmTerrainSemanticRow } from '../../game/map/realmTerrainSemantics'; + +export type RealmWaterInspectionRecord = Readonly<{ + cellKey: string; + coord: HexCoord; + bodyId: string; + regime: 'ocean' | 'river'; + displayType: 'river' | 'coast' | 'outer-sea'; + displayName: string; + description: string; + riverOrdinal?: number; + riverPosition?: 'source' | 'upper reach' | 'middle reach' | 'lower reach' | 'mouth'; + riverOrder?: number; + riverCellCount?: number; + sourceCellKey?: string; + mouthCellKey?: string; + sourceCoord?: HexCoord; + mouthCoord?: HexCoord; + downstreamWaterCellKey?: string; + flowClass?: 'headwater' | 'branching reach' | 'main reach' | 'lower reach'; + oceanDepthClass?: 'coast' | 'open water'; + depthCells: number; + fogBand: 'clear' | 'haze'; + underlyingTileKey?: string; + underlyingPassable?: boolean; + worldPosition: Readonly<{ x: number; z: number }>; + gameplayBoundary: string; +}>; + +export type RealmWaterNavigatorBody = Readonly<{ + bodyId: string; + label: string; + sourceCellKey: string; + mouthCellKey: string; + sourceCoord: HexCoord; + mouthCoord: HexCoord; +}>; + +function safeBodyMap(bodies: readonly GenesisWaterBodyV1[]) { + return new Map(bodies.map((body) => [body.bodyId, body] as const)); +} + +function riverPosition(cell: GenesisWaterCellV1, body: GenesisWaterBodyV1) { + const order = cell.riverOrder ?? 0; + const progress = body.cellCount <= 1 ? 0 : order / Math.max(1, body.cellCount - 1); + if (order === 0) return 'source' as const; + if (order >= body.cellCount - 1) return 'mouth' as const; + if (progress < 0.34) return 'upper reach' as const; + if (progress < 0.67) return 'middle reach' as const; + return 'lower reach' as const; +} + +function riverFlowClass(cell: GenesisWaterCellV1) { + if (cell.flowAccumulation <= 1) return 'headwater' as const; + if (cell.flowAccumulation <= 3) return 'branching reach' as const; + if (cell.flowAccumulation <= 8) return 'main reach' as const; + return 'lower reach' as const; +} + +function coordForCell( + cellsByKey: ReadonlyMap, + cellKey: string +) { + const cell = cellsByKey.get(cellKey); + return cell ? { q: cell.q, r: cell.r } : undefined; +} + +/** + * Build bounded, read-only records from the already validated Water subset. + * Full-fog cells and any unexpected lake rows fail closed rather than creating + * browser-local identity or a misleading inspection surface. + */ +export function resolveRealmWaterInspectionRecords( + cells: readonly GenesisWaterCellV1[] | undefined, + terrainMetadata: readonly RealmTerrainSemanticRow[] = [], + bodies: readonly GenesisWaterBodyV1[] = GENESIS_WATER_BODIES_V1 +): readonly RealmWaterInspectionRecord[] { + void terrainMetadata; + if (!cells || cells.some((cell) => cell.regime === 'lake')) return Object.freeze([]); + const bodyMap = safeBodyMap(bodies); + const cellsByKey = new Map(cells.map((cell) => [cell.cellKey, cell] as const)); + const records: RealmWaterInspectionRecord[] = []; + + for (const cell of cells) { + if ( + cell.regime !== 'ocean' && cell.regime !== 'river' + || cell.fogBand === 'full' + || !Number.isSafeInteger(cell.q) + || !Number.isSafeInteger(cell.r) + ) continue; + const body = bodyMap.get(cell.bodyId); + if (!body || body.regime !== cell.regime) return Object.freeze([]); + const coord = { q: cell.q, r: cell.r }; + if (cell.regime === 'river') { + const ordinal = body.ordinal; + const riverName = `Genesis River ${String(ordinal).padStart(2, '0')}`; + const sourceCoord = coordForCell(cellsByKey, body.sourceCellKey); + const mouthCoord = coordForCell(cellsByKey, body.mouthCellKey); + records.push(Object.freeze({ + cellKey: cell.cellKey, + coord, + bodyId: cell.bodyId, + regime: 'river', + displayType: 'river', + displayName: riverName, + description: 'A persistent Genesis watercourse crossing the Lowlands from source to mouth.', + riverOrdinal: ordinal, + riverPosition: riverPosition(cell, body), + riverOrder: cell.riverOrder, + riverCellCount: body.cellCount, + sourceCellKey: body.sourceCellKey, + mouthCellKey: body.mouthCellKey, + sourceCoord, + mouthCoord, + downstreamWaterCellKey: cell.downstreamWaterCellKey, + flowClass: riverFlowClass(cell), + depthCells: cell.depthCells, + fogBand: cell.fogBand, + underlyingTileKey: cell.underlyingTileKey, + underlyingPassable: undefined, + worldPosition: axialToWorld(coord, 1), + gameplayBoundary: 'Water is visual and does not add boats, swimming, current force, or resource rewards.' + })); + continue; + } + const isCoast = cell.oceanDepth <= 2; + records.push(Object.freeze({ + cellKey: cell.cellKey, + coord, + bodyId: cell.bodyId, + regime: 'ocean', + displayType: isCoast ? 'coast' : 'outer-sea', + displayName: isCoast ? 'Lowlands Coast' : 'Outer Sea', + description: isCoast + ? 'A clear coastal water cell at the edge of the Lowlands.' + : 'Open water beyond the coast, presented within the public fog boundary.', + oceanDepthClass: isCoast ? 'coast' : 'open water', + depthCells: cell.depthCells, + fogBand: cell.fogBand, + worldPosition: axialToWorld(coord, 1), + gameplayBoundary: 'The sea is a shared visual boundary; it is not claimable territory.' + })); + } + return Object.freeze(records); +} + +export function realmWaterNavigatorBodies( + records: readonly RealmWaterInspectionRecord[] +): readonly RealmWaterNavigatorBody[] { + const byBody = new Map(); + records.forEach((record) => { + if (record.regime === 'river' && !byBody.has(record.bodyId)) byBody.set(record.bodyId, record); + }); + return Object.freeze([...byBody.values()] + .sort((left, right) => (left.riverOrdinal ?? 0) - (right.riverOrdinal ?? 0)) + .map((record) => Object.freeze({ + bodyId: record.bodyId, + label: record.displayName, + sourceCellKey: record.sourceCellKey!, + mouthCellKey: record.mouthCellKey!, + sourceCoord: record.sourceCoord!, + mouthCoord: record.mouthCoord! + }))); +} diff --git a/src/components/realm/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index cadc3d88..633a011f 100644 --- a/src/components/realm/realmWaterLayer.ts +++ b/src/components/realm/realmWaterLayer.ts @@ -57,8 +57,20 @@ export type RealmWaterLayerTelemetry = Readonly<{ fullFogOceanCellCount: number; }>; +export type RealmWaterCellHit = Readonly<{ + cellKey: string; + bodyId: string; + regime: 'ocean' | 'river'; + coord: Readonly<{ q: number; r: number }>; + distance: number; +}>; + export type RealmWaterLayer = Readonly<{ group: THREE.Group; + raycast: (raycaster: THREE.Raycaster) => RealmWaterCellHit | null; + getCellPresentation: (cellKey: string) => GenesisWaterCellV1 | undefined; + setSelectedCellKey: (cellKey: string | null) => void; + setHoveredCellKey: (cellKey: string | null) => void; updateEnvironment: (elapsedSeconds: number) => boolean; isAnimationActive: () => boolean; getTelemetry: () => RealmWaterLayerTelemetry; @@ -134,6 +146,7 @@ function surfaceGeometry( geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); + geometry.userData.realmWaterCellKeys = cells.map((cell) => cell.cellKey); try { geometry.setIndex(indices); geometry.computeBoundingSphere(); @@ -269,6 +282,7 @@ function riverSurfaceGeometry( geometry.setAttribute('waterDepth', new THREE.Float32BufferAttribute(waterDepth, 1)); geometry.setAttribute('waterBankBlend', new THREE.Float32BufferAttribute(waterBankBlend, 1)); geometry.setAttribute('waterFogMix', new THREE.Float32BufferAttribute(waterFogMix, 1)); + geometry.userData.realmWaterCellKeys = cells.map((cell) => cell.cellKey); try { geometry.setIndex(indices); geometry.computeBoundingSphere(); @@ -432,6 +446,84 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay skirtMesh.name = 'canonical-ocean-downward-skirt'; riverMesh.renderOrder = 2; skirtMesh.renderOrder = 1; + const cellsByKey = new Map(options.cells.map((cell) => [cell.cellKey, cell] as const)); + const visiblePickCells = new Set(options.cells + .filter((cell) => cell.regime !== 'ocean' || cell.fogBand !== 'full') + .map((cell) => cell.cellKey)); + const selectedWaterOverlay = new THREE.LineLoop( + new THREE.BufferGeometry(), + new THREE.LineBasicMaterial({ + color: '#e8fbce', + transparent: true, + opacity: 0.94, + depthTest: false, + depthWrite: false, + toneMapped: false + }) + ); + const hoveredWaterOverlay = new THREE.LineLoop( + new THREE.BufferGeometry(), + new THREE.LineBasicMaterial({ + color: '#d3f4ec', + transparent: true, + opacity: 0.6, + depthTest: false, + depthWrite: false, + toneMapped: false + }) + ); + selectedWaterOverlay.name = 'selected-water-cell-outline'; + hoveredWaterOverlay.name = 'hovered-water-cell-outline'; + selectedWaterOverlay.renderOrder = 6; + hoveredWaterOverlay.renderOrder = 5; + selectedWaterOverlay.visible = false; + hoveredWaterOverlay.visible = false; + group.add(selectedWaterOverlay, hoveredWaterOverlay); + const updateWaterOverlay = ( + overlay: THREE.LineLoop, + cellKey: string | null, + opacity: number + ) => { + const cell = cellKey ? cellsByKey.get(cellKey) : undefined; + if (!cell || !visiblePickCells.has(cell.cellKey)) { + overlay.visible = false; + return; + } + const center = axialToWorld({ q: cell.q, r: cell.r }, options.hexSize); + const corners = pointyHexCorners({ q: cell.q, r: cell.r }, options.hexSize); + const ground = cell.regime === 'river' + ? Math.max( + waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT, + options.heightAtWorld(center) + 0.035 + ) + : waterSurfaceLevelToWorldY(cell.surfaceLevelMilli) + WATER_Y_LIFT; + const positions = new Float32Array(corners.length * 3); + corners.forEach((corner, index) => { + positions[index * 3] = corner.x; + positions[index * 3 + 1] = ground + (opacity > 0.8 ? 0.018 : 0.012); + positions[index * 3 + 2] = corner.z; + }); + overlay.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + overlay.geometry.computeBoundingSphere(); + (overlay.material as THREE.LineBasicMaterial).opacity = opacity; + overlay.visible = true; + }; + const raycastMesh = (mesh: THREE.Mesh, raycaster: THREE.Raycaster) => { + const intersection = raycaster.intersectObject(mesh, false)[0]; + if (!intersection || intersection.faceIndex === undefined || intersection.faceIndex === null) return null; + const keys = mesh.geometry.userData.realmWaterCellKeys as readonly string[] | undefined; + const cellKey = keys?.[Math.floor(intersection.faceIndex / 6)]; + if (!cellKey || !visiblePickCells.has(cellKey)) return null; + const cell = cellsByKey.get(cellKey); + if (!cell || (cell.regime !== 'ocean' && cell.regime !== 'river')) return null; + return { + cellKey, + bodyId: cell.bodyId, + regime: cell.regime, + coord: { q: cell.q, r: cell.r }, + distance: intersection.distance + } satisfies RealmWaterCellHit; + }; group.add(oceanMesh, lakeMesh, riverMesh, skirtMesh); const triangleCount = (oceanGeometry.index?.count ?? 0) / 3 + (lakeGeometry.index?.count ?? 0) / 3 @@ -440,6 +532,10 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay const drawCalls = [oceanMesh, lakeMesh, riverMesh, skirtMesh] .filter((mesh) => (mesh.geometry.index?.count ?? 0) > 0).length; if (triangleCount > budget.triangles || drawCalls > budget.draws) { + selectedWaterOverlay.geometry.dispose(); + (selectedWaterOverlay.material as THREE.Material).dispose(); + hoveredWaterOverlay.geometry.dispose(); + (hoveredWaterOverlay.material as THREE.Material).dispose(); disposeResources(); throw new Error('REALM_WATER_RENDER_BUDGET_EXCEEDED'); } @@ -474,7 +570,27 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay dispose: () => { if (disposed) return; disposed = true; + selectedWaterOverlay.geometry.dispose(); + (selectedWaterOverlay.material as THREE.Material).dispose(); + hoveredWaterOverlay.geometry.dispose(); + (hoveredWaterOverlay.material as THREE.Material).dispose(); disposeResources(); + }, + raycast: (raycaster) => { + if (disposed) return null; + const hits = [raycastMesh(oceanMesh, raycaster), raycastMesh(lakeMesh, raycaster), raycastMesh(riverMesh, raycaster)] + .filter((hit): hit is RealmWaterCellHit => hit !== null) + .sort((left, right) => left.distance - right.distance); + return hits[0] ?? null; + }, + getCellPresentation: (cellKey) => cellsByKey.get(cellKey), + setSelectedCellKey: (cellKey) => { + if (disposed) return; + updateWaterOverlay(selectedWaterOverlay, cellKey, 0.94); + }, + setHoveredCellKey: (cellKey) => { + if (disposed) return; + updateWaterOverlay(hoveredWaterOverlay, cellKey, 0.6); } }; } diff --git a/tests/realmInteractionState.test.ts b/tests/realmInteractionState.test.ts index 92a13ff7..54e53bf5 100644 --- a/tests/realmInteractionState.test.ts +++ b/tests/realmInteractionState.test.ts @@ -86,6 +86,34 @@ describe('realm interaction state', () => { expect(resolveRealmEscape(state).state.keyboardIntent.target).toEqual({ kind: 'map' }); }); + it('opens a read-only water record and preserves its canonical body identity', () => { + const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { + type: 'activate-water-cell', + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 4, r: -2 } + }); + + expect(state.selectedCell).toEqual({ q: 4, r: -2 }); + expect(state.selectedCastle).toBeNull(); + expect(state.inspectorTarget).toEqual({ + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 4, r: -2 } + }); + expect(state.cameraTarget).toEqual({ + kind: 'water', + cellKey: 'genesis-001:river:01:0001', + coord: { q: 4, r: -2 } + }); + expect(state.keyboardIntent).toEqual({ + sequence: 1, + target: { kind: 'water-inspector', cellKey: 'genesis-001:river:01:0001' } + }); + }); + it('opens a Food Farm inspector through a distinct target shape from Gold', () => { const state = realmInteractionReducer(createRealmInteractionState({ q: 0, r: 0 }), { type: 'activate-food-site', diff --git a/tests/realmPickArbitration.test.ts b/tests/realmPickArbitration.test.ts index 76332dec..4ad9aefd 100644 --- a/tests/realmPickArbitration.test.ts +++ b/tests/realmPickArbitration.test.ts @@ -101,4 +101,45 @@ describe('realm scene pick arbitration', () => { terrainHit: { coord: { q: -2, r: 1 } } })).toEqual({ kind: 'terrain', coord: { q: -2, r: 1 } }); }); + + it('places a visible water cell after static sites and before terrain', () => { + expect(arbitrateRealmPick({ + resourceHits: [], + waterHit: { + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 2, r: -1 }, + distance: 8 + }, + terrainHit: { coord: { q: 2, r: -1 } } + })).toEqual({ + kind: 'water-cell', + cellKey: 'genesis-001:river:01:0001', + bodyId: 'genesis-001:river:01', + regime: 'river', + coord: { q: 2, r: -1 } + }); + expect(arbitrateRealmPick({ + resourceHits: [{ + kind: 'gold-site', + siteId: 'gold-1', + coord: { q: 2, r: -1 }, + source: 'site', + distance: 12 + }], + waterHit: { + cellKey: 'ocean:1', + bodyId: 'genesis-001:ocean', + regime: 'ocean', + coord: { q: 2, r: -1 }, + distance: 1 + } + })).toEqual({ + kind: 'gold-site', + siteId: 'gold-1', + coord: { q: 2, r: -1 }, + source: 'site' + }); + }); }); diff --git a/tests/realmWaterInspectionPresentation.test.ts b/tests/realmWaterInspectionPresentation.test.ts new file mode 100644 index 00000000..7a54332e --- /dev/null +++ b/tests/realmWaterInspectionPresentation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + GENESIS_WATER_BODIES_V1, + GENESIS_WATER_CELLS_V1 +} from '../spacetimedb/src/waterWorld'; +import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1 } from '../spacetimedb/src/waterRevision'; +import { + realmWaterNavigatorBodies, + resolveRealmWaterInspectionRecords +} from '../src/components/realm/realmWaterInspectionPresentation'; + +describe('public water inspection presentation', () => { + it('creates bounded river records with source, mouth, flow, and fog facts', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + const rivers = records.filter((record) => record.regime === 'river'); + expect(rivers).toHaveLength(400); + expect(rivers[0]).toMatchObject({ + displayType: 'river', + displayName: expect.stringMatching(/^Genesis River /), + sourceCellKey: expect.any(String), + mouthCellKey: expect.any(String), + riverCellCount: expect.any(Number), + riverPosition: expect.any(String), + flowClass: expect.any(String), + gameplayBoundary: expect.stringContaining('does not add') + }); + }); + + it('shows clear and haze public ocean cells but never full-fog ocean cells', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + expect(records.some((record) => record.regime === 'ocean' && record.fogBand === 'clear')).toBe(true); + expect(records.some((record) => record.regime === 'ocean' && record.fogBand === 'haze')).toBe(true); + }); + + it('creates one navigator entry per river body rather than one row per cell', () => { + const records = resolveRealmWaterInspectionRecords(GENESIS_WATER_REVISION_ENABLED_CELLS_V1); + const bodies = realmWaterNavigatorBodies(records); + expect(bodies).toHaveLength(GENESIS_WATER_BODIES_V1.filter((body) => body.regime === 'river').length); + expect(new Set(bodies.map((body) => body.bodyId)).size).toBe(bodies.length); + expect(bodies[0]).toMatchObject({ + label: expect.stringMatching(/^Genesis River /), + sourceCellKey: expect.any(String), + mouthCellKey: expect.any(String) + }); + }); + + it('fails closed for lake rows or malformed body identity', () => { + expect(resolveRealmWaterInspectionRecords(GENESIS_WATER_CELLS_V1)).toEqual([]); + const lake = GENESIS_WATER_CELLS_V1.find((cell) => cell.regime === 'lake'); + expect(lake).toBeDefined(); + expect(resolveRealmWaterInspectionRecords([lake!])).toEqual([]); + const river = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find((cell) => cell.regime === 'river'); + expect(river).toBeDefined(); + expect(resolveRealmWaterInspectionRecords([ + { ...river!, bodyId: 'unexpected-body' } + ])).toEqual([]); + }); +}); diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index fdaec85e..c8f1aed3 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -172,6 +172,39 @@ describe('Realm canonical water layer', () => { layer.dispose(); }); + it('maps bounded ray hits back to one visible water cell and excludes full fog', () => { + const layer = createRealmWaterLayer({ + cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, + quality: REALM_QUALITY_SPECS.reduced, + reducedMotion: true, + hexSize: 1, + heightAtWorld: canonicalHeightAtWorld + }); + const river = activeRiverCells[0]!; + const riverWorld = axialToWorld(river, 1); + const raycaster = new THREE.Raycaster( + new THREE.Vector3(riverWorld.x, 10, riverWorld.z), + new THREE.Vector3(0, -1, 0) + ); + expect(layer.raycast(raycaster)).toMatchObject({ + cellKey: river.cellKey, + bodyId: river.bodyId, + regime: 'river', + coord: { q: river.q, r: river.r } + }); + const fullFog = GENESIS_WATER_REVISION_ENABLED_CELLS_V1.find( + (cell) => cell.regime === 'ocean' && cell.fogBand === 'full' + ); + expect(fullFog).toBeDefined(); + const fogWorld = axialToWorld(fullFog!, 1); + const fogRaycaster = new THREE.Raycaster( + new THREE.Vector3(fogWorld.x, 10, fogWorld.z), + new THREE.Vector3(0, -1, 0) + ); + expect(layer.raycast(fogRaycaster)).toBeNull(); + layer.dispose(); + }); + it('keeps every canonical river surface clear and every shared edge continuous', () => { const layer = createRealmWaterLayer({ cells: GENESIS_WATER_REVISION_ENABLED_CELLS_V1, @@ -341,8 +374,8 @@ describe('Realm canonical water layer', () => { hexSize: 1, heightAtWorld: canonicalHeightAtWorld })).toThrow('REALM_WATER_RENDER_BUDGET_EXCEEDED'); - expect(geometryDispose).toHaveBeenCalledTimes(4); - expect(materialDispose).toHaveBeenCalledTimes(4); + expect(geometryDispose).toHaveBeenCalledTimes(6); + expect(materialDispose).toHaveBeenCalledTimes(6); } finally { geometryDispose.mockRestore(); materialDispose.mockRestore(); From 6231a949320a660c29a27ca99259a4eb8ec07389 Mon Sep 17 00:00:00 2001 From: Ael Date: Wed, 22 Jul 2026 00:47:19 +0200 Subject: [PATCH 2/2] fix: expose underlying terrain in water records --- src/components/realm/WaterInspectionPanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/realm/WaterInspectionPanel.tsx b/src/components/realm/WaterInspectionPanel.tsx index dfd4af18..578c47a7 100644 --- a/src/components/realm/WaterInspectionPanel.tsx +++ b/src/components/realm/WaterInspectionPanel.tsx @@ -117,6 +117,7 @@ export function WaterInspectionPanel({ +