diff --git a/package.json b/package.json index 97d8e4d..151f212 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "pg": "^8.13.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.7", "stats.js": "^0.17.0", "tailwind-merge": "^2.5.5", "three": "0.149.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4419ecf..be3f1e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.6(react@19.2.6) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) stats.js: specifier: ^0.17.0 version: 0.17.0 @@ -2224,6 +2227,12 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4344,6 +4353,11 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/public/student-space/textures/cliff-soft-strata.png b/public/student-space/textures/cliff-soft-strata.png new file mode 100644 index 0000000..589261e Binary files /dev/null and b/public/student-space/textures/cliff-soft-strata.png differ diff --git a/public/student-space/textures/sand-soft-ripples.png b/public/student-space/textures/sand-soft-ripples.png new file mode 100644 index 0000000..e0e2d76 Binary files /dev/null and b/public/student-space/textures/sand-soft-ripples.png differ diff --git a/public/student-space/textures/water-foam-cells.png b/public/student-space/textures/water-foam-cells.png new file mode 100644 index 0000000..2cbe08c Binary files /dev/null and b/public/student-space/textures/water-foam-cells.png differ diff --git a/public/student-space/textures/water-short-bubbles.png b/public/student-space/textures/water-short-bubbles.png new file mode 100644 index 0000000..a00d37c Binary files /dev/null and b/public/student-space/textures/water-short-bubbles.png differ diff --git a/src/components/IslandProgressionOverlay.tsx b/src/components/IslandProgressionOverlay.tsx index c419d9b..dca2b92 100644 --- a/src/components/IslandProgressionOverlay.tsx +++ b/src/components/IslandProgressionOverlay.tsx @@ -1,5 +1,6 @@ -import { Check, Pencil } from 'lucide-react' +import { Check, Pencil, X } from 'lucide-react' import { useEffect, useState } from 'react' +import { toast as sonnerToast, Toaster } from 'sonner' import { WorldIconButton } from '~/components/student-space/hud/StudentSpaceHud' import type { Game } from '~/engine/student-space/Game' @@ -13,18 +14,12 @@ import type { Game } from '~/engine/student-space/Game' * automatically when the threshold-crossing capture lands, so there's no * "ready and waiting" state for the student to discover. * - * useSyncExternalStore is no longer needed since the only thing rendered - * is the toast stack — toasts are local component state, driven directly - * by the slice's subscribe callback inside useEffect. + * Sonner owns the toast stack; this component only bridges world events to + * top-screen notifications and keeps the arrange toggle in the frame overlay. */ -type Toast = { - id: number - text: string - variant: 'grow' | 'ready' | 'bloom' -} - const TOAST_TTL_MS = 2400 +const PROGRESSION_TOAST_ID = 'student-space-progression' function getSproutsSlice(game: Game) { // Defensive: tests sometimes pass a partial game without a state surface. @@ -43,35 +38,34 @@ function getSproutsSlice(game: Game) { const FIRST_ARRANGE_TOAST_KEY = 'ss:arrange:firstEntry:v1' +function showProgressionToast(text: string, duration = Infinity) { + sonnerToast.custom( + (id) => ( +
+ {text} + +
+ ), + { duration, id: PROGRESSION_TOAST_ID }, + ) +} + export function IslandProgressionOverlay({ game }: { game: Game }) { - const [toasts, setToasts] = useState([]) const [editMode, setEditMode] = useState(false) useEffect(() => { - let nextId = 1 const sprouts = getSproutsSlice(game) if (!sprouts?.subscribe) return const unsubscribe = sprouts.subscribe((event) => { - let entry: Toast | null = null if (event.type === 'spawned') { - entry = { - id: nextId++, - text: 'Heard. Something is growing on the island.', - variant: 'grow', - } - } else if (event.type === 'grew') { - entry = { id: nextId++, text: 'Heard. The sprout grew.', variant: 'grow' } - } else if (event.type === 'markedReady') { - entry = { id: nextId++, text: 'This one’s ready to plant.', variant: 'ready' } - } else if (event.type === 'bloomed') { - entry = { id: nextId++, text: 'Planted. A new tree on the island.', variant: 'bloom' } - } - if (entry) { - const fresh: Toast = entry - setToasts((prev) => [...prev, fresh]) - window.setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== fresh.id)) - }, TOAST_TTL_MS) + showProgressionToast('Heard. Something is growing on the island.') } }) @@ -83,15 +77,7 @@ export function IslandProgressionOverlay({ game }: { game: Game }) { const ce = e as CustomEvent<{ count?: number; threshold?: number }> const count = ce.detail?.count ?? 0 const threshold = ce.detail?.threshold ?? 0 - const tip: Toast = { - id: nextId++, - text: `Still growing — ${count}/${threshold}.`, - variant: 'grow', - } - setToasts((prev) => [...prev, tip]) - window.setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== tip.id)) - }, TOAST_TTL_MS) + showProgressionToast(`Still growing — ${count}/${threshold}.`, TOAST_TTL_MS) } window.addEventListener('ss:sprout-tap-not-ready', onNotReady) @@ -112,15 +98,10 @@ export function IslandProgressionOverlay({ game }: { game: Game }) { try { if (!window.sessionStorage.getItem(FIRST_ARRANGE_TOAST_KEY)) { window.sessionStorage.setItem(FIRST_ARRANGE_TOAST_KEY, '1') - const toast: Toast = { - id: Date.now(), - text: 'Drag any of your things to plant them somewhere new.', - variant: 'ready', - } - setToasts((current) => [...current, toast]) - window.setTimeout(() => { - setToasts((current) => current.filter((t) => t.id !== toast.id)) - }, TOAST_TTL_MS + 1200) + showProgressionToast( + 'Drag any of your things to plant them somewhere new.', + TOAST_TTL_MS + 1200, + ) } } catch (_) { /* sessionStorage blocked — banner is enough */ @@ -149,6 +130,14 @@ export function IslandProgressionOverlay({ game }: { game: Game }) { }} data-island-progression-overlay > + {editMode ? ( ) } diff --git a/src/components/student-space/capture/AskSheet.tsx b/src/components/student-space/capture/AskSheet.tsx index a0059c9..e846e9a 100644 --- a/src/components/student-space/capture/AskSheet.tsx +++ b/src/components/student-space/capture/AskSheet.tsx @@ -65,6 +65,9 @@ type PreparedReflection = { mood?: string transcription?: unknown } + +const ASK_CAPTURE_COMMITTED_EVENT = 'ss:ask-capture-committed' +const DEFAULT_CAPTURE_PROMPT = "What's on your mind right now?" type RealtimeCapture = { stop?: () => Promise abort?: () => void @@ -173,6 +176,15 @@ function preparedToReframe(prepared: PreparedReflection): Reframe { } } +function dispatchAskCaptureCommitted(captureId: string | null | undefined) { + if (typeof window === 'undefined') return + window.dispatchEvent( + new CustomEvent(ASK_CAPTURE_COMMITTED_EVENT, { + detail: { captureId: captureId ?? null }, + }), + ) +} + function readImageAsDataUrl(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -195,11 +207,7 @@ export function AskSheet() { const letterId = (options?.letterId as string | undefined) ?? null const readOnly = Boolean(options?.readOnly) const dismissOnBack = Boolean(options?.dismissOnBack) - // Onboarding mode — the first-capture stage opens the sheet with this - // flag so commit fires a one-shot event (FirstCapture listens for it - // and advances to bloom-celebrate). The reframe/chat detour is skipped - // so the ceremony lands directly on the bloom moment. - const onboardingFlag = Boolean(options?.onboarding) + const visiblePrompt = prompt?.trim() || (!readOnly ? DEFAULT_CAPTURE_PROMPT : '') const capture = options?.capture as CaptureEntry | undefined const prefilledText = (options?.prefilledText as string | undefined) ?? '' const backend = engine?.state?.backend @@ -430,21 +438,28 @@ export function AskSheet() { async function startRecording() { const runId = recordingRunRef.current + 1 recordingRunRef.current = runId - // Onboarding skips realtime Mirror so the first capture lands cleanly - // in review → commit without detouring through the reframe stage. const useRealtimeVoice = Boolean( - backend?.createRealtimeMirrorCapture && canCreateRealtimeMirrorCapture() && !onboardingFlag, + backend?.createRealtimeMirrorCapture && canCreateRealtimeMirrorCapture(), ) if ((!useRealtimeVoice && !canRecordStudentSpaceAudio()) || listening) return setListening(true) setHint('') setLiveHint('') - setLiveDialogue([]) const seed = text.trim() + const openingPrompt = prompt?.trim() || "I'm here. What's on your mind from today?" setReviewText(seed) setStage('recording') - if (seed) - setLiveDialogue([{ id: 'typed-preface', role: 'student', text: seed, status: 'final' }]) + setLiveDialogue([ + { id: 'kira-opening', role: 'kira', text: openingPrompt, status: 'final' }, + seed + ? { id: 'typed-preface', role: 'student', text: seed, status: 'final' } + : { + id: 'student-listening-placeholder', + role: 'student', + text: 'Listening...', + status: 'streaming', + }, + ]) try { if (useRealtimeVoice) { @@ -457,9 +472,20 @@ export function AskSheet() { if (!mountedRef.current || recordingRunRef.current !== runId) return setLiveDialogue((items) => { const id = message.id || `${message.role || 'student'}-${Date.now()}` + const role = + message.role === 'assistant' || message.role === 'kira' + ? 'kira' + : message.role === 'user' || message.role === 'student' + ? 'student' + : message.role if (message.status === 'discarded') return items.filter((item) => item.id !== id) - const next = items.filter((item) => item.id !== id) - next.push({ ...message, id }) + const next = items.filter((item) => { + if (item.id === id) return false + if (role === 'student' && item.id === 'student-listening-placeholder') return false + if (role === 'kira' && item.id === 'kira-opening') return false + return true + }) + next.push({ ...message, id, role }) return next }) if (message.role === 'student' && message.status === 'final' && message.text) { @@ -506,40 +532,27 @@ export function AskSheet() { const liveRealtimeCapture = realtimeCaptureRef.current if (liveRealtimeCapture) { const session = liveRealtimeCapture + const transcriptSoFar = (liveStudentText || reviewText || text.trim()).trim() setRealtimeCaptureHandle(null) setPrepareInFlight(true) setPreparedReflection(null) - setReframe({ - headline: 'Mirroring and summarising the session.', - highlightPhrase: reviewText || 'Voice reflection', - themes: [], - needs: [], - moods: selectedMood ? [selectedMood] : ['ennui'], - }) + setReviewText(transcriptSoFar) setReframeActionMode('preparing') - setStage('reframe') + setStage('review') try { const prepared = await session.stop?.() if (!mountedRef.current || recordingRunRef.current !== runId) return setPrepareInFlight(false) if (!prepared) throw new Error('Realtime Mirror returned no reading.') setPreparedReflection(prepared) - const transcript = prepared.transcript || reviewText + const transcript = prepared.transcript || transcriptSoFar setReviewText(transcript) setReframe(preparedToReframe(prepared)) setReframeActionMode('ready') - } catch (err) { + } catch (_err) { if (!mountedRef.current || recordingRunRef.current !== runId) return - const message = err instanceof Error ? err.message : String(err) setPrepareInFlight(false) setPreparedReflection(null) - setReframe({ - headline: `Could not prepare this reading yet. ${message}`, - highlightPhrase: reviewText || 'Voice reflection', - themes: [], - needs: [], - moods: ['ennui'], - }) setReframeActionMode('failed') } return @@ -575,13 +588,6 @@ export function AskSheet() { setStage('compose') return } - // Onboarding lands a single capture — no reframe detour. If the user - // taps "Reflect" from the review stage, we commit what they have and - // close so the bloom ceremony picks up immediately. - if (onboardingFlag) { - logReview() - return - } if (!backend?.prepareReflection || prepareInFlight) { const offline = reframeFor(nextText) setReframe({ ...offline, edited: reframe?.edited === true }) @@ -667,13 +673,7 @@ export function AskSheet() { if (captureEntry && backend?.submitReflection) { void submitBackendReflection(captureEntry, options) } - if (onboardingFlag && typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('ss:onboarding-capture-committed', { - detail: { captureId: captureEntry?.id ?? null }, - }), - ) - } + dispatchAskCaptureCommitted(captureEntry?.id) } async function submitBackendReflection( @@ -718,28 +718,28 @@ export function AskSheet() { async function logPreparedReframe() { if (logInFlight || !preparedReflection) return - const runId = workflowRunRef.current + const prepared = preparedReflection setLogInFlight(true) setReframeActionMode('logging') const captureEntry = captures?.add?.({ - id: preparedReflection.localCaptureId, + id: prepared.localCaptureId, kind: 'ask', prompt, - text: preparedReflection.transcript || '', + text: prepared.transcript || '', reframe, syncStatus: backend?.logPreparedReflection ? 'syncing' : 'local', - contextType: preparedReflection.contextType || 'school', + contextType: prepared.contextType || 'school', ...(uploadedImageDataUrl ? { dataUrl: uploadedImageDataUrl } : {}), ...(letterId ? { letterId } : {}), }) + dispatchAskCaptureCommitted(captureEntry?.id) + close() if (!backend?.logPreparedReflection) { - close() return } try { - const result = await backend.logPreparedReflection(preparedReflection) + const result = await backend.logPreparedReflection(prepared) const mirror = result?.mirrorEntry - if (!isLiveWorkflow(runId)) return if (mirror && captureEntry?.id) { captures?.patch?.(captureEntry.id, { backendMirrorEntryId: mirror.id, @@ -759,14 +759,11 @@ export function AskSheet() { }, }) } - close() } catch (err) { - if (!isLiveWorkflow(runId)) return const message = err instanceof Error ? err.message : String(err) console.warn('[AskSheet] prepared reflection log failed', err) if (captureEntry?.id) captures?.patch?.(captureEntry.id, { syncStatus: 'failed', syncError: message }) - close() } } @@ -796,6 +793,10 @@ export function AskSheet() { } function logReview() { + if (preparedReflection) { + void logPreparedReframe() + return + } const nextText = reviewText.trim() if (!nextText && !recordedAudioBlob) return commitCapture( @@ -941,7 +942,12 @@ export function AskSheet() { > {stagePillLabel} -
+
{stage === 'compose' ? (
{letter ? ( @@ -953,7 +959,9 @@ export function AskSheet() { {letter.subject ? ` - ${letter.subject}` : ''} ) : null} - {prompt ?

{prompt}

: null} + {visiblePrompt ? ( +

{visiblePrompt}

+ ) : null} {uploadedImageDataUrl ? (
@@ -1124,10 +1132,10 @@ export function AskSheet() { ) : null} {stage === 'recording' ? ( -
+
@@ -1395,39 +1403,41 @@ function ReframeStage({ } function TypingIndicator({ label }: { label: string }) { + const visibleLabel = label.replace(/\.+$/, '') || 'Listening' + return ( -
- {label} +
+ {visibleLabel}
) } function ReframeReadout({ reframe, busy }: { reframe: Reframe | null; busy?: boolean }) { - const moods = reframe?.moods?.length ? reframe.moods.slice(0, 2) : ['ennui'] const themes = reframe?.themes ?? [] + return ( -
- +
+ {reframe?.highlightPhrase ? ( +

{reframe.highlightPhrase}

+ ) : null} {themes.length > 0 ? ( -
+
{themes.slice(0, 3).map((theme, index) => { const pill = THEME_PILL[theme] || { label: theme, @@ -1446,23 +1456,10 @@ function ReframeReadout({ reframe, busy }: { reframe: Reframe | null; busy?: boo })}
) : null} - {reframe?.highlightPhrase ? ( -
- {reframe.highlightPhrase} -
- ) : null} -

Reading

{busy ? ( -
-
+ ) : ( -

+

{reframe?.headline || ''}

)} diff --git a/src/components/student-space/onboarding/FirstCapture.tsx b/src/components/student-space/onboarding/FirstCapture.tsx index c8a174a..95ecf43 100644 --- a/src/components/student-space/onboarding/FirstCapture.tsx +++ b/src/components/student-space/onboarding/FirstCapture.tsx @@ -5,14 +5,13 @@ import { useEngineOverlay } from '~/lib/student-space/use-engine-overlay' /** * `first-capture` stage surface. * - * Headless — it owns no DOM of its own. On mount it opens the AskSheet - * with the onboarding flag set, listens for the commit event the sheet - * dispatches, and advances to `bloom-celebrate`. If the user closes the - * sheet without committing we re-open it on the next tick so the only - * way out of this stage is to actually share something (or use the - * SkipButton). + * Headless — it owns no DOM of its own. On mount it opens the same + * AskSheet used from the home capture button, listens for its commit + * event, and advances to `bloom-celebrate`. If the user closes the sheet + * without committing we re-open it on the next tick so the only way out + * of this stage is to actually share something (or use the SkipButton). */ -const ONBOARDING_COMMIT_EVENT = 'ss:onboarding-capture-committed' +const ASK_CAPTURE_COMMITTED_EVENT = 'ss:ask-capture-committed' export function FirstCapture({ onAdvance }: { onAdvance: () => void }) { const overlay = useEngineOverlay() @@ -32,9 +31,9 @@ export function FirstCapture({ onAdvance }: { onAdvance: () => void }) { // while the bloom ceremony tries to run behind it. window.setTimeout(() => onAdvance(), 0) } - window.addEventListener(ONBOARDING_COMMIT_EVENT, handler) + window.addEventListener(ASK_CAPTURE_COMMITTED_EVENT, handler) return () => { - window.removeEventListener(ONBOARDING_COMMIT_EVENT, handler) + window.removeEventListener(ASK_CAPTURE_COMMITTED_EVENT, handler) } }, [onAdvance]) @@ -45,7 +44,6 @@ export function FirstCapture({ onAdvance }: { onAdvance: () => void }) { if (committedRef.current) return openCaptureRef.current('ask', { prompt: ONBOARDING_COPY.firstCapture.prompt, - onboarding: true, }) }, 80) return () => window.clearTimeout(id) diff --git a/src/engine/student-space/Game/State/Island.js b/src/engine/student-space/Game/State/Island.js index a2fa073..158bd01 100644 --- a/src/engine/student-space/Game/State/Island.js +++ b/src/engine/student-space/Game/State/Island.js @@ -18,7 +18,7 @@ export default class Island constructor() { this.radius = 5.0 // plateau radius (m) - this.sandOuterRadius = 7.2 // visible beach reach before water + this.sandOuterRadius = 8.2 // visible beach reach before water this.plateauTopY = 1.0 // top of the grass plateau this.sandTopY = 0.18 // sand ring top elevation this.cliffHeight = 0.55 // cliff face between sand and plateau @@ -34,6 +34,8 @@ export default class Island + Math.sin(theta * 2.0 + 0.7) * 0.13 + Math.sin(theta * 3.0 - 1.3) * 0.07 + Math.sin(theta * 5.0 + 2.1) * 0.04 + + Math.sin(theta * 7.0 - 0.4) * 0.018 + + Math.sin(theta * 9.0 + 1.8) * 0.012 } radiusAtTheta(theta, baseRadius = this.radius) diff --git a/src/engine/student-space/Game/View/Island.js b/src/engine/student-space/Game/View/Island.js index d3848c1..d990133 100644 --- a/src/engine/student-space/Game/View/Island.js +++ b/src/engine/student-space/Game/View/Island.js @@ -28,6 +28,19 @@ const SEA = new THREE.Color(0x2A8CA0) const SEA_DEEP = new THREE.Color(0x1560A0) const FOAM = new THREE.Color(0xB3FFFF) +// Asset paths mirror Tree/Kira: derive from Vite's BASE_URL for subpath +// deploys, with "/" as the unit-test/SSR fallback. +const BASE_URL = (typeof import.meta !== 'undefined' + && import.meta.env + && typeof import.meta.env.BASE_URL === 'string') + ? import.meta.env.BASE_URL + : '/' +const ASSET_BASE = BASE_URL.endsWith('/') ? BASE_URL : `${BASE_URL}/` +const SAND_TEXTURE_URL = `${ASSET_BASE}student-space/textures/sand-soft-ripples.png` +const CLIFF_TEXTURE_URL = `${ASSET_BASE}student-space/textures/cliff-soft-strata.png` +const WATER_FOAM_CELLS_TEXTURE_URL = `${ASSET_BASE}student-space/textures/water-foam-cells.png` +const WATER_SHORT_BUBBLES_TEXTURE_URL = `${ASSET_BASE}student-space/textures/water-short-bubbles.png` + function smoothstep(edge0, edge1, value) { const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))) @@ -38,9 +51,9 @@ function sandRippleAt(theta, t) { const innerFade = smoothstep(0.06, 0.24, t) const outerFade = 1 - smoothstep(0.78, 1.0, t) - const bands = Math.sin(t * 68 + theta * 4.5) * 0.04 - const cross = Math.sin(theta * 13.0 + t * 17.0) * 0.022 - const scallop = Math.sin(theta * 19.0) * 0.018 * (1 - t) + const bands = Math.sin(t * 18 + theta * 2.5) * 0.012 + const cross = Math.sin(theta * 5.0 + t * 7.0) * 0.006 + const scallop = Math.sin(theta * 7.0) * 0.004 * (1 - t) return (bands + cross + scallop) * innerFade * outerFade } @@ -236,18 +249,106 @@ export default class Island uCurveK: { value: CURVE_K }, uCurveStrength: { value: CURVE_STRENGTH }, } + this._shoreUniforms = { + uShoreTime: { value: 0 }, + } // Onbeforecompile shaders captured here for legacy MeshLambert paths // (sand + cliff) — kept so a future studio control can update them. this._curvedShaders = [] + this.assetsFailed = false this._buildTerrainTexture() + this._loadSandTexture() + this._loadCliffTexture() + this._loadWaterTextures() this._buildPlateau() this._buildSand() this._buildCliff() this._buildWater() } + _loadSandTexture() + { + this.sandTexture = new THREE.TextureLoader().load( + SAND_TEXTURE_URL, + (tex) => + { + tex.colorSpace = THREE.SRGBColorSpace + tex.wrapS = THREE.RepeatWrapping + tex.wrapT = THREE.RepeatWrapping + tex.magFilter = THREE.LinearFilter + tex.minFilter = THREE.LinearMipmapLinearFilter + tex.generateMipmaps = true + tex.needsUpdate = true + }, + undefined, + (err) => + { + this.assetsFailed = true + console.error('[engine] island assets failed to load (sand texture)', err) + }, + ) + } + + _loadCliffTexture() + { + this.cliffTexture = new THREE.TextureLoader().load( + CLIFF_TEXTURE_URL, + (tex) => + { + tex.colorSpace = THREE.SRGBColorSpace + tex.wrapS = THREE.RepeatWrapping + tex.wrapT = THREE.RepeatWrapping + tex.magFilter = THREE.LinearFilter + tex.minFilter = THREE.LinearMipmapLinearFilter + tex.generateMipmaps = true + tex.needsUpdate = true + }, + undefined, + (err) => + { + this.assetsFailed = true + console.error('[engine] island assets failed to load (cliff texture)', err) + }, + ) + } + + _loadWaterTextures() + { + const configureMask = (tex) => + { + tex.wrapS = THREE.RepeatWrapping + tex.wrapT = THREE.RepeatWrapping + tex.magFilter = THREE.LinearFilter + tex.minFilter = THREE.LinearMipmapLinearFilter + tex.generateMipmaps = true + tex.needsUpdate = true + } + + this.waterFoamCellsTexture = new THREE.TextureLoader().load( + WATER_FOAM_CELLS_TEXTURE_URL, + configureMask, + undefined, + (err) => + { + this.assetsFailed = true + console.error('[engine] island assets failed to load (water foam cells)', err) + }, + ) + + this.waterShortBubblesTexture = new THREE.TextureLoader().load( + WATER_SHORT_BUBBLES_TEXTURE_URL, + configureMask, + undefined, + (err) => + { + this.assetsFailed = true + console.error('[engine] island assets failed to load (water short bubbles)', err) + }, + ) + } + _buildTerrainTexture() { const size = this.textureSize @@ -303,11 +404,18 @@ export default class Island if(detailKind) { + if(detailKind === 'sand') + shader.uniforms.uSandTexture = { value: this.sandTexture } + if(detailKind === 'cliff') + shader.uniforms.uCliffTexture = { value: this.cliffTexture } + shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include varying vec3 vIslandWorld; + ${detailKind === 'sand' ? 'uniform sampler2D uSandTexture;' : ''} + ${detailKind === 'cliff' ? 'uniform sampler2D uCliffTexture;' : ''} float islandHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } @@ -325,23 +433,30 @@ export default class Island .replace( 'vec4 diffuseColor = vec4( diffuse, opacity );', detailKind === 'sand' - ? `vec3 detailDiffuse = diffuse; + ? `vec2 sandUv = vIslandWorld.xz * 0.36 + vec2(0.03, -0.02); + vec3 detailDiffuse = texture2D(uSandTexture, sandUv).rgb; float sandR = length(vIslandWorld.xz); - float grain = islandNoise(vIslandWorld.xz * 14.0); float broad = islandNoise(vIslandWorld.xz * 2.2); float shell = smoothstep(5.0, 7.25, sandR); - float wet = 1.0 - smoothstep(-0.28, 0.10, vIslandWorld.y); - float rings = sin(sandR * 11.0 + islandNoise(vIslandWorld.xz * 3.0) * 3.0) * 0.5 + 0.5; - detailDiffuse = mix(detailDiffuse * 0.92, detailDiffuse * 1.08, broad); - detailDiffuse = mix(detailDiffuse, vec3(0.86, 0.74, 0.42), rings * 0.2 * (1.0 - wet)); - detailDiffuse = mix(detailDiffuse, vec3(0.62, 0.54, 0.36), wet * 0.42); - detailDiffuse += vec3((grain - 0.5) * 0.13); - detailDiffuse *= 1.0 - shell * 0.08; + float wet = smoothstep(-0.205, -0.175, vIslandWorld.y) + * (1.0 - smoothstep(-0.130, -0.095, vIslandWorld.y)); + float wetSand = smoothstep(-0.192, -0.168, vIslandWorld.y) + * (1.0 - smoothstep(-0.135, -0.112, vIslandWorld.y)); + float softRings = sin(sandR * 5.4 + islandNoise(vIslandWorld.xz * 1.8) * 2.0) * 0.5 + 0.5; + detailDiffuse = mix(detailDiffuse * 0.94, detailDiffuse * 1.04, broad); + detailDiffuse = mix(detailDiffuse, vec3(0.96, 0.82, 0.50), softRings * 0.08 * (1.0 - wet)); + detailDiffuse = mix(detailDiffuse, vec3(0.72, 0.58, 0.36), wet * 0.28); + detailDiffuse = mix(detailDiffuse, vec3(0.56, 0.43, 0.26), wetSand * 0.46); + detailDiffuse *= 1.0 - shell * 0.045; vec4 diffuseColor = vec4( detailDiffuse, opacity );` - : `vec3 detailDiffuse = diffuse; - float layer = sin(vIslandWorld.y * 34.0 + islandNoise(vIslandWorld.xz * 2.6) * 4.0) * 0.5 + 0.5; - float chips = islandNoise(vIslandWorld.xz * 10.0 + vIslandWorld.y); - detailDiffuse = mix(detailDiffuse * 0.78, detailDiffuse * 1.12, layer * 0.32 + chips * 0.18); + : `float cliffTheta = atan(vIslandWorld.z, vIslandWorld.x) / 6.28318530718 + 0.5; + vec2 cliffUv = vec2(cliffTheta * 7.0 + vIslandWorld.y * 0.32, vIslandWorld.y * 2.4 + 0.08); + vec3 detailDiffuse = texture2D(uCliffTexture, cliffUv).rgb; + float verticalShade = islandNoise(vec2(cliffTheta * 12.0, 0.0)) * 0.5 + 0.5; + float foot = 1.0 - smoothstep(0.18, 0.34, vIslandWorld.y); + detailDiffuse = mix(detailDiffuse, vec3(0.56, 0.34, 0.18), 0.10); + detailDiffuse = mix(detailDiffuse * 0.94, detailDiffuse * 1.05, verticalShade * 0.18); + detailDiffuse = mix(detailDiffuse, vec3(0.78, 0.47, 0.24), foot * 0.18); vec4 diffuseColor = vec4( detailDiffuse, opacity );`, ) } @@ -449,7 +564,7 @@ export default class Island // and water at y=-0.15, the sand surface crosses the water line at // t = 0.33 / 0.85 roughly 0.39, so part of the ring is visible dry beach // and the rest disappears underwater, occluded by the water mesh. - const mat = new THREE.MeshLambertMaterial({ color: 0xd0b478 }) + const mat = new THREE.MeshLambertMaterial({ color: 0xffffff }) this._applyCurvedEarth(mat, 'sand') this.sand = new THREE.Mesh(ring, mat) this.scene.add(this.sand) @@ -458,7 +573,7 @@ export default class Island _buildCliff() { const geo = buildCliffGeometry(this.island, 192) - const mat = new THREE.MeshLambertMaterial({ color: 0x8a6a30 }) + const mat = new THREE.MeshLambertMaterial({ color: 0xffffff }) this._applyCurvedEarth(mat, 'cliff') this.cliff = new THREE.Mesh(geo, mat) this.scene.add(this.cliff) @@ -498,6 +613,8 @@ export default class Island uDeep: { value: SEA_DEEP.clone() }, uFoam: { value: FOAM.clone() }, uSkyTint: { value: new THREE.Color(0xffffff) }, + uFoamCells: { value: this.waterFoamCellsTexture }, + uShortBubbles: { value: this.waterShortBubblesTexture }, uIslandR: { value: islandR }, uWaveAmp: { value: 0.18 }, // 0 = calm, 1 = downpour. Modulates wave amplitude in @@ -559,6 +676,8 @@ export default class Island uniform vec3 uDeep; uniform vec3 uFoam; uniform vec3 uSkyTint; + uniform sampler2D uFoamCells; + uniform sampler2D uShortBubbles; uniform float uIslandR; uniform float uTime; @@ -570,7 +689,9 @@ export default class Island return 1.0 + sin(theta * 2.0 + 0.7) * 0.13 + sin(theta * 3.0 - 1.3) * 0.07 - + sin(theta * 5.0 + 2.1) * 0.04; + + sin(theta * 5.0 + 2.1) * 0.04 + + sin(theta * 7.0 - 0.4) * 0.018 + + sin(theta * 9.0 + 1.8) * 0.012; } void main() { @@ -602,11 +723,8 @@ export default class Island float shallowness = 1.0 - depthT; /* ----- ORGANIC FOAM PATTERN ————————————————————————————— - * Seven multiplied sines that occasionally align near - * zero — the lacy caustic pattern you see on a pool - * floor. Direct port of TinySkies' ocean, but dialed - * way down so the surface reads CALM and graphic - * instead of busy. ----- */ + * Subtle texture-authored lacy foam, backed by the older + * sine mask so the water still animates gently. */ float w1 = sin(ox * 2.15 + oy * 1.35 + y * 0.55 + t * 3.6) * 0.5 + 0.5; float w2 = sin(oy * 1.85 + y * 2.65 + ox * 0.35 - t * 2.7) * 0.5 + 0.5; float w3 = sin(y * 1.55 + ox * 0.95 + oy * 2.35 + t * 2.1) * 0.5 + 0.5; @@ -616,9 +734,20 @@ export default class Island float w7 = sin(ox * 3.35 - y * 2.15 + oy * 0.15 - t * 0.9) * 0.5 + 0.5; float blobs = w1 * w2 * w4 * w6 + w3 * w5 * w7 * 0.3; blobs = 1.0 - smoothstep(0.002, 0.012, blobs); - // Keep the shore zone clean so the foam edge reads first. - blobs *= smoothstep(shoreR + 1.2, shoreR + 4.0, r); - col += vec3(0.7, 1.0, 1.0) * blobs * mix(0.012, 0.06, shallowness); + float openWaterTexture = blobs * smoothstep(shoreR + 1.0, shoreR + 3.2, r) + * (1.0 - smoothstep(shoreR + 22.0, shoreR + 32.0, r)); + col += vec3(0.62, 0.94, 1.0) * openWaterTexture * mix(0.010, 0.045, shallowness); + + // Keep generated foam assets tight to the shore so they + // read as bubbles on the line, not a tiled ocean pattern. + vec2 foamCellUv = vXZ * 0.18 + vec2(uTime * 0.010, -uTime * 0.006); + float foamCells = texture2D(uFoamCells, foamCellUv).r; + foamCells = smoothstep(0.56, 0.84, foamCells); + float foamCellShoreT = clamp((r - shoreR) / 2.2, 0.0, 1.0); + float foamCellsBand = smoothstep(0.02, 0.08, foamCellShoreT) + * (1.0 - smoothstep(0.20, 0.34, foamCellShoreT)); + float organicFoam = max(blobs * 0.08, foamCells * foamCellsBand * 0.26); + col = mix(col, uFoam, organicFoam * mix(0.04, 0.16, shallowness)); /* ----- SPARKLES ————————————————————————————————————————— * Sparse pinpoint highlights gated by a slow macro mask @@ -650,31 +779,56 @@ export default class Island * silhouette-aware shoreR so they hug the peanut * shape. Kept subtle so they whisper outward from * the shore instead of competing with the halo. */ - float edgeFoam = smoothstep(shoreR + 0.70, shoreR + 0.10, r) - * smoothstep(shoreR - 0.40, shoreR + 0.20, r); - - float shoreT = clamp((r - shoreR) / 6.0, 0.0, 1.0); - float noiseOff = sin(ox * 1.2 + oy * 0.8 + uTime * 0.5) * 0.05; - // + time → rings drift INWARD toward the shore (waves - // rolling in). - time would scroll outward (radiating). - float contour = fract((shoreT + noiseOff) * 4.0 + uTime * 0.10); + float rawShoreDist = r - shoreR; + float shoreT = clamp(rawShoreDist / 6.0, 0.0, 1.0); + float noiseOff = sin(ox * 1.2 + oy * 0.8) * 0.05; + float shoreWave = sin(theta * 7.0) + + sin(theta * 13.0 + 1.7) * 0.45 + + noiseOff * 4.0; + float shoreOffset = shoreWave * 0.035; + float shoreDist = rawShoreDist + shoreOffset; + float wetTint = (1.0 - smoothstep(0.02, 0.36, shoreDist)) + * smoothstep(-0.18, 0.02, shoreDist); + float paleWash = smoothstep(0.10, 0.55, shoreDist) + * (1.0 - smoothstep(1.05, 1.85, shoreDist)); + float contactLip = smoothstep(-0.08, 0.02, rawShoreDist) + * (1.0 - smoothstep(0.16, 0.34, rawShoreDist)); + float foamLip = smoothstep(-0.02, 0.10, shoreDist) + * (1.0 - smoothstep(0.22, 0.42, shoreDist)); + col = mix(col, vec3(0.10, 0.55, 0.58), wetTint * 0.32); + col = mix(col, vec3(0.62, 0.90, 0.82), max(paleWash * 0.50, contactLip * 0.32)); + + float movingWashPhase = sin(shoreDist * 2.4 + t * 1.05 + theta * 1.6 + noiseOff * 6.0) * 0.5 + 0.5; + float movingWash = smoothstep(0.36, 0.84, movingWashPhase) + * smoothstep(0.22, 0.60, shoreDist) + * (1.0 - smoothstep(2.0, 3.4, shoreDist)); + col = mix(col, vec3(0.70, 0.98, 0.88), movingWash * 0.12); + + float contour = fract((shoreT + noiseOff) * 4.0 + t * 0.16); float ringMask = smoothstep(0.82, 0.95, contour) * (1.0 - smoothstep(0.95, 1.00, contour)); float ringFade = (1.0 - smoothstep(0.05, 0.55, shoreT)) * smoothstep(0.04, 0.10, shoreT); float ripples = ringMask * ringFade; + vec2 bubbleUv = vXZ * 0.16; + float shortBubbles = texture2D(uShortBubbles, bubbleUv).r; + shortBubbles = smoothstep(0.42, 0.74, shortBubbles); + float shortBubbleBand = (1.0 - smoothstep(0.01, 0.38, shoreT)) + * smoothstep(0.00, 0.045, shoreT); + shortBubbles *= shortBubbleBand; + /* ----- SHORELINE FLOW ————————————————————————————————— - * Modulate the halo's brightness with two slow waves - * that travel ALONG the shoreline (in theta) at - * different speeds + directions. Stays confined to - * the edgeFoam band so it never bleeds into the - * ocean — reads as water washing along the beach. */ - float flowA = 0.5 + 0.5 * sin(theta * 3.0 + uTime * 0.90); - float flowB = 0.5 + 0.5 * sin(theta * 5.0 - uTime * 1.30 + 1.7); + * Modulate the halo's brightness along the shoreline. It + * remains time-free so the white lip stays locked while + * the aqua ripple layers above continue to move. */ + float flowA = 0.5 + 0.5 * sin(theta * 3.0); + float flowB = 0.5 + 0.5 * sin(theta * 5.0 + 1.7); float foamFlow = mix(flowA, flowB, 0.5); - col = mix(col, uFoam, edgeFoam * (0.75 + foamFlow * 0.45)); - col = mix(col, uFoam, ripples * 0.32); + vec3 shoreWhite = vec3(0.96, 1.0, 0.92); + col = mix(col, shoreWhite, max(contactLip * 0.46, foamLip * (0.52 + foamFlow * 0.20))); + col = mix(col, shoreWhite, ripples * 0.20); + col = mix(col, shoreWhite, shortBubbles * 0.62); // Wave-crest highlight — much softer now (water is calm). col += vec3(0.10) * max(0.0, vWave) * 3.0; @@ -706,6 +860,7 @@ export default class Island const rain = this.state.weather ? this.state.weather.rain : 0 const dt = this.state.time.delta || 0 this._oceanTime += dt * (0.45 + rain * 0.55) + this._shoreUniforms.uShoreTime.value = this._oceanTime this.waterMat.uniforms.uTime.value = this._oceanTime this.waterMat.uniforms.uRain.value = rain const day = this.state.day.currentState diff --git a/test/components/IslandProgressionOverlay.test.tsx b/test/components/IslandProgressionOverlay.test.tsx index af9a0d9..850fd99 100644 --- a/test/components/IslandProgressionOverlay.test.tsx +++ b/test/components/IslandProgressionOverlay.test.tsx @@ -10,6 +10,7 @@ * the slice itself by Sprouts.test.ts. */ import { act, render, screen, waitFor } from '@testing-library/react' +import { toast as sonnerToast } from 'sonner' import { afterEach, describe, expect, it } from 'vitest' import { IslandProgressionOverlay } from '~/components/IslandProgressionOverlay' @@ -55,7 +56,7 @@ function makeFakeGame(): { game: Game; sprouts: FakeSprouts } { } afterEach(() => { - // Defaults are fine — no global singletons. + sonnerToast.dismiss() }) describe('IslandProgressionOverlay', () => { @@ -67,27 +68,22 @@ describe('IslandProgressionOverlay', () => { expect(screen.queryByText(/heard/i)).toBeNull() }) - it('renders a toast on grow events and removes it after the TTL', async () => { + it('renders the progression toast on spawn events', async () => { const { game, sprouts } = makeFakeGame() render() act(() => { sprouts.emit({ type: 'spawned' }) }) - expect(screen.getByText(/heard\. something is growing/i)).toBeInTheDocument() - // TTL is 2.4s; we don't fast-forward timers here, just assert the - // toast surfaces. Auto-dismiss is exercised by the e2e test. - await waitFor(() => expect(screen.queryByText(/heard\. something is growing/i)).toBeNull(), { - timeout: 3000, - }) + expect(await screen.findByText(/heard\. something is growing/i)).toBeInTheDocument() }) - it('renders bloom toast distinctly from grow', () => { + it('does not surface bloom events as separate progression toasts', async () => { const { game, sprouts } = makeFakeGame() render() act(() => { sprouts.emit({ type: 'bloomed' }) }) - expect(screen.getByText(/planted\. a new tree/i)).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText(/planted\. a new tree/i)).toBeNull()) }) it('renders nothing-breaking with a partial game (no sprouts slice)', () => { @@ -100,7 +96,7 @@ describe('IslandProgressionOverlay', () => { expect(screen.queryByRole('status')).toBeNull() }) - it('surfaces a "still growing" toast on the ss:sprout-tap-not-ready CustomEvent', () => { + it('surfaces a "still growing" toast on the ss:sprout-tap-not-ready CustomEvent', async () => { const { game } = makeFakeGame() render() act(() => { @@ -110,7 +106,7 @@ describe('IslandProgressionOverlay', () => { }), ) }) - expect(screen.getByText(/still growing — 2\/3/i)).toBeInTheDocument() + expect(await screen.findByText(/still growing — 2\/3/i)).toBeInTheDocument() }) it('unmounts the not-ready event listener on cleanup', () => { diff --git a/test/components/student-space/capture/capture-stack.test.tsx b/test/components/student-space/capture/capture-stack.test.tsx index 6f4532f..67d4e09 100644 --- a/test/components/student-space/capture/capture-stack.test.tsx +++ b/test/components/student-space/capture/capture-stack.test.tsx @@ -260,9 +260,7 @@ describe('React capture stack', () => { await userEvent.click(screen.getByRole('button', { name: 'Done' })) await waitFor(() => expect(stop).toHaveBeenCalledTimes(1)) - await waitFor(() => - expect(screen.getByText(/Kira heard the Realtime session/)).toBeInTheDocument(), - ) + await waitFor(() => expect(screen.getByText('realtime transcript')).toBeInTheDocument()) await userEvent.click(screen.getByRole('button', { name: 'Log' })) await waitFor(() => expect(logPreparedReflection).toHaveBeenCalledTimes(1)) @@ -298,8 +296,8 @@ describe('React capture stack', () => { await waitFor(() => expect(createRealtimeMirrorCapture).toHaveBeenCalledTimes(1)) expect(screen.getByText('You')).toBeInTheDocument() - expect(screen.getByRole('status', { name: 'Listening...' })).toBeInTheDocument() - expect(screen.getByRole('log')).toHaveClass('flex-1', 'overflow-y-auto') + expect(screen.getByRole('status', { name: 'Listening' })).toBeInTheDocument() + expect(screen.getByRole('log')).toHaveClass('overflow-y-auto') }) it('turns the companion toward the camera while Ask capture is open', async () => { diff --git a/test/engine/Progression.e2e.test.tsx b/test/engine/Progression.e2e.test.tsx index 6165ba7..673fbb6 100644 --- a/test/engine/Progression.e2e.test.tsx +++ b/test/engine/Progression.e2e.test.tsx @@ -19,6 +19,7 @@ * - the cross-slice subscription survives the React render cycle */ import { act, render, screen } from '@testing-library/react' +import { toast as sonnerToast } from 'sonner' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { IslandProgressionOverlay } from '~/components/IslandProgressionOverlay' @@ -67,6 +68,7 @@ function expectReadySprout(sprouts: Sprouts) { } afterEach(() => { + sonnerToast.dismiss() resetSingletons() }) @@ -78,12 +80,12 @@ describe('island progression — captures → sprouts → overlay e2e', () => { bundle = buildFakeGame() }) - it('a single capture surfaces the spawn toast', () => { + it('a single capture surfaces the spawn toast', async () => { render() act(() => { bundle.captures.add({ kind: 'ask', text: 'hello' }) }) - expect(screen.getByText(/heard\. something is growing/i)).toBeInTheDocument() + expect(await screen.findByText(/heard\. something is growing/i)).toBeInTheDocument() }) it('threshold-crossing capture flips the sprout to readyToBloom', () => { @@ -100,7 +102,7 @@ describe('island progression — captures → sprouts → overlay e2e', () => { expect(bundle.sprouts.readyToBloom()).toHaveLength(1) }) - it('explicit bloom() removes the sprout and surfaces the planted toast', () => { + it('explicit bloom() removes the sprout without adding a second progression toast', () => { render() act(() => { for (let i = 0; i < BLOOM_THRESHOLD; i++) { @@ -111,7 +113,7 @@ describe('island progression — captures → sprouts → overlay e2e', () => { act(() => { bundle.sprouts.bloom(ready.id) }) - expect(screen.getByText(/planted\. a new tree/i)).toBeInTheDocument() + expect(screen.queryByText(/planted\. a new tree/i)).toBeNull() expect(bundle.sprouts.listBloomedTrees()).toHaveLength(1) expect(bundle.sprouts.recent(10)).toHaveLength(0) })