Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 45 additions & 211 deletions brainsnn-r3f-app/src/app/GaugeGapLanding.jsx

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions brainsnn-r3f-app/src/features/gaugegap/ArcadeProgress.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, Flame, Medal, Star, Trophy } from 'lucide-react';

const STORAGE_KEY = 'gaugegap-arcade-progress-v1';

function dateKey(date = new Date()) {
return date.toISOString().slice(0, 10);
}
Comment on lines +6 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using date.toISOString() returns the date in UTC. This can cause daily streaks and missions to reset or fail to increment unexpectedly depending on the user's local timezone (e.g., if a user plays twice on consecutive local days but those visits fall on the same or non-consecutive UTC days). Formatting the date string using local time ensures consistent daily streak behavior.

function dateKey(date = new Date()) {
  const yyyy = date.getFullYear();
  const mm = String(date.getMonth() + 1).padStart(2, '0');
  const dd = String(date.getDate()).padStart(2, '0');
  return `${yyyy}-${mm}-${dd}`;
}


function yesterdayKey() {
const date = new Date();
date.setDate(date.getDate() - 1);
return dateKey(date);
}

function readProgress() {
if (typeof window === 'undefined') return { visited: [], xp: 0, streak: 0, lastVisit: '', dailyWins: [] };
try {
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}');
return {
visited: Array.isArray(parsed.visited) ? parsed.visited : [],
xp: Number(parsed.xp) || 0,
streak: Number(parsed.streak) || 0,
lastVisit: parsed.lastVisit || '',
dailyWins: Array.isArray(parsed.dailyWins) ? parsed.dailyWins : [],
};
} catch {
return { visited: [], xp: 0, streak: 0, lastVisit: '', dailyWins: [] };
}
}

function dailyIndex(length) {
const key = dateKey();
let hash = 0;
for (let index = 0; index < key.length; index += 1) hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
return length ? hash % length : 0;
}

export function useArcadeProgress(experiments) {
const [progress, setProgress] = useState(readProgress);
const dailyLab = experiments[dailyIndex(experiments.length)] || experiments[0];

useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
}, [progress]);

const recordVisit = useCallback((id) => {
const today = dateKey();
setProgress((current) => {
const firstVisit = !current.visited.includes(id);
const firstToday = current.lastVisit !== today;
const dailyWin = id === dailyLab?.id && !current.dailyWins.includes(today);
let streak = current.streak;
if (firstToday) streak = current.lastVisit === yesterdayKey() ? Math.max(1, current.streak + 1) : 1;
return {
visited: firstVisit ? [...current.visited, id] : current.visited,
xp: current.xp + (firstVisit ? 25 : 2) + (dailyWin ? 50 : 0),
streak,
lastVisit: today,
dailyWins: dailyWin ? [...current.dailyWins.slice(-29), today] : current.dailyWins,
};
});
}, [dailyLab?.id]);

const level = Math.floor(progress.xp / 125) + 1;
const levelProgress = progress.xp % 125;
const achievements = useMemo(() => [
{ id: 'first', label: 'First Contact', unlocked: progress.visited.length >= 1 },
{ id: 'sampler', label: 'System Sampler', unlocked: progress.visited.length >= 4 },
{ id: 'polymath', label: 'Arcade Polymath', unlocked: progress.visited.length >= 8 },
{ id: 'completionist', label: 'Foundry Completionist', unlocked: progress.visited.length >= experiments.length },
{ id: 'streak', label: 'Three-Day Signal', unlocked: progress.streak >= 3 },
{ id: 'daily', label: 'Daily Challenger', unlocked: progress.dailyWins.length >= 3 },
], [experiments.length, progress.dailyWins.length, progress.streak, progress.visited.length]);

return { progress, recordVisit, dailyLab, level, levelProgress, achievements };
}

export function ArcadeProgress({ progress, level, levelProgress, achievements, dailyLab, onOpenDaily }) {
const unlocked = achievements.filter((achievement) => achievement.unlocked).length;
const dailyDone = progress.dailyWins.includes(dateKey());
return (
<section className="gg-passport" aria-label="GaugeGap arcade passport">
<div className="gg-passport-main">
<div className="gg-passport-level"><Trophy size={20} /><span>Level {level}</span><strong>{progress.xp} XP</strong></div>
<div className="gg-passport-progress"><span style={{ width: `${Math.max(4, levelProgress / 1.25)}%` }} /></div>
<small>{125 - levelProgress} XP until the next level · {progress.visited.length} labs explored</small>
</div>
<button type="button" className={`gg-daily-card ${dailyDone ? 'complete' : ''}`} onClick={onOpenDaily}>
<CalendarDays size={19} />
<span><small>{dailyDone ? 'Daily mission complete' : 'Today’s mission'}</small><strong>{dailyLab?.title || 'Surprise lab'}</strong></span>
<em>{dailyDone ? 'Done' : '+50 XP'}</em>
</button>
<div className="gg-passport-stat"><Flame size={18} /><span><strong>{progress.streak}</strong><small>day streak</small></span></div>
<div className="gg-passport-stat"><Medal size={18} /><span><strong>{unlocked}/{achievements.length}</strong><small>badges</small></span></div>
<details className="gg-achievements">
<summary><Star size={16} /> Achievements</summary>
<div>{achievements.map((achievement) => <span key={achievement.id} className={achievement.unlocked ? 'unlocked' : ''}>{achievement.label}</span>)}</div>
</details>
</section>
);
}
140 changes: 35 additions & 105 deletions brainsnn-r3f-app/src/features/gaugegap/ExperimentArcade.jsx
Original file line number Diff line number Diff line change
@@ -1,93 +1,29 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Activity, CircleDot, Dna, Gauge, Orbit, Shield, Shuffle, Sparkles, Waves, Wind } from 'lucide-react';
import { Activity, Bug, Car, CircleDot, Dna, Gauge, Grid3X3, Leaf, Orbit, Shield, Shuffle, Sparkles, Waves, Wind } from 'lucide-react';
import { AttractorPlayground } from './AttractorPlayground.jsx';
import { FireflySyncLab, ReactionDiffusionLab, WaveInterferenceLab } from './CollectivePlaygrounds.jsx';
import { FlockMindLab, OutbreakZeroLab } from './AgentPlaygrounds.jsx';
import { ChaosTwinsLab, GravityForgeLab } from './PhysicsPlaygrounds.jsx';
import { AntTrailLab, TrafficTamerLab } from './PlayfulPlaygrounds.jsx';
import { EcosystemBalanceLab, LifePainterLab } from './WorldPlaygrounds.jsx';
import { ArcadeProgress, useArcadeProgress } from './ArcadeProgress.jsx';
import '../../styles/arcade.css';
import '../../styles/deep-arcade.css';
import '../../styles/fun-arcade.css';

const EXPERIMENTS = [
{
id: 'attractor',
number: '001',
title: 'Butterfly Effect',
short: 'Shape chaos',
description: 'Tune a Lorenz system until order and turbulence balance on the same orbit.',
icon: Gauge,
accent: 'cyan',
mechanic: 'Tune',
},
{
id: 'fireflies',
number: '002',
title: 'Firefly Sync',
short: 'Create collective rhythm',
description: 'Give hundreds of independent clocks one weak connection and watch a shared pulse emerge.',
icon: Sparkles,
accent: 'lime',
mechanic: 'Synchronize',
},
{
id: 'waves',
number: '003',
title: 'Wave Eraser',
short: 'Find perfect silence',
description: 'Move through two overlapping waves and hunt for the places where energy disappears.',
icon: Waves,
accent: 'violet',
mechanic: 'Explore',
},
{
id: 'reaction',
number: '004',
title: 'Living Chemistry',
short: 'Draw a new species',
description: 'Paint two virtual chemicals and grow coral, cells, worms and mazes from local reactions.',
icon: Dna,
accent: 'pink',
mechanic: 'Create',
},
{
id: 'gravity',
number: '005',
title: 'Gravity Forge',
short: 'Build a solar system',
description: 'Launch planets into a live n-body field and see whether your orbital architecture survives.',
icon: Orbit,
accent: 'amber',
mechanic: 'Construct',
},
{
id: 'flock',
number: '006',
title: 'Flock Mind',
short: 'Steer emergence',
description: 'Act as predator or beacon while leaderless agents coordinate through only local rules.',
icon: Wind,
accent: 'blue',
mechanic: 'Influence',
},
{
id: 'outbreak',
number: '007',
title: 'Outbreak Zero',
short: 'Contain the network',
description: 'Choose patient zero, spend twelve vaccines and break the transmission graph before it turns red.',
icon: Shield,
accent: 'red',
mechanic: 'Intervene',
},
{
id: 'pendulum',
number: '008',
title: 'Chaos Twins',
short: 'Race two futures',
description: 'Start two double pendulums almost identically and measure how quickly their futures separate.',
icon: CircleDot,
accent: 'orange',
mechanic: 'Predict',
},
{ id: 'attractor', number: '001', title: 'Butterfly Effect', short: 'Shape chaos', description: 'Tune a Lorenz system until order and turbulence balance on the same orbit.', icon: Gauge, accent: 'cyan', mechanic: 'Tune' },
{ id: 'fireflies', number: '002', title: 'Firefly Sync', short: 'Create collective rhythm', description: 'Give hundreds of independent clocks one weak connection and watch a shared pulse emerge.', icon: Sparkles, accent: 'lime', mechanic: 'Synchronize' },
{ id: 'waves', number: '003', title: 'Wave Eraser', short: 'Find perfect silence', description: 'Move through two overlapping waves and hunt for the places where energy disappears.', icon: Waves, accent: 'violet', mechanic: 'Explore' },
{ id: 'reaction', number: '004', title: 'Living Chemistry', short: 'Draw a new species', description: 'Paint two virtual chemicals and grow coral, cells, worms and mazes from local reactions.', icon: Dna, accent: 'pink', mechanic: 'Create' },
{ id: 'gravity', number: '005', title: 'Gravity Forge', short: 'Build a solar system', description: 'Launch planets into a live n-body field and see whether your orbital architecture survives.', icon: Orbit, accent: 'amber', mechanic: 'Construct' },
{ id: 'flock', number: '006', title: 'Flock Mind', short: 'Steer emergence', description: 'Act as predator or beacon while leaderless agents coordinate through only local rules.', icon: Wind, accent: 'blue', mechanic: 'Influence' },
{ id: 'outbreak', number: '007', title: 'Outbreak Zero', short: 'Contain the network', description: 'Choose patient zero, spend twelve vaccines and break the transmission graph before it turns red.', icon: Shield, accent: 'red', mechanic: 'Intervene' },
{ id: 'pendulum', number: '008', title: 'Chaos Twins', short: 'Race two futures', description: 'Start two double pendulums almost identically and measure how quickly their futures separate.', icon: CircleDot, accent: 'orange', mechanic: 'Predict' },
{ id: 'ants', number: '009', title: 'Ant Trail Architect', short: 'Design without a planner', description: 'Place food and barriers while a leaderless colony discovers and reinforces efficient routes.', icon: Bug, accent: 'mint', mechanic: 'Route' },
{ id: 'traffic', number: '010', title: 'Traffic Tamer', short: 'Beat the intersection', description: 'Control signal phases under rising demand and keep every queue from becoming a citywide jam.', icon: Car, accent: 'yellow', mechanic: 'Control' },
{ id: 'life', number: '011', title: 'Life Painter', short: 'Paint computation', description: 'Draw living cells and let four tiny rules transform them into moving, evolving machines.', icon: Grid3X3, accent: 'teal', mechanic: 'Invent' },
{ id: 'ecosystem', number: '012', title: 'Ecosystem Keeper', short: 'Balance a living world', description: 'Add energy, prey or predators and keep the entire food web alive through delayed feedback.', icon: Leaf, accent: 'green', mechanic: 'Balance' },
];

function initialExperiment() {
Expand All @@ -99,6 +35,9 @@ function initialExperiment() {
export function ExperimentArcade() {
const [active, setActive] = useState(initialExperiment);
const activeExperiment = useMemo(() => EXPERIMENTS.find((experiment) => experiment.id === active) || EXPERIMENTS[0], [active]);
const { progress, recordVisit, dailyLab, level, levelProgress, achievements } = useArcadeProgress(EXPERIMENTS);

useEffect(() => { recordVisit(active); }, [active, recordVisit]);

useEffect(() => {
function selectFromEvent(event) {
Expand All @@ -117,9 +56,7 @@ export function ExperimentArcade() {
url.searchParams.delete('state');
url.hash = 'playground';
window.history.replaceState({}, '', url);
if (scroll) {
window.requestAnimationFrame(() => document.getElementById('playground')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}
if (scroll) window.requestAnimationFrame(() => document.getElementById('playground')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}

function surpriseMe() {
Expand All @@ -132,41 +69,34 @@ export function ExperimentArcade() {
<div className="gg-arcade-intro">
<div>
<p className="gg-kicker"><Activity size={16} /> GaugeGap science arcade</p>
<h2 id="gg-arcade-title">Eight experiments. Eight different ways to think.</h2>
<p>Tune, synchronize, explore, draw, construct, influence, intervene and predict. Every lab begins as play, then exposes the scientific model underneath.</p>
<h2 id="gg-arcade-title">Twelve experiments. Twelve ways to play with reality.</h2>
<p>Tune, synchronize, explore, draw, construct, influence, intervene, predict, route, control, invent and balance. The library now behaves like an arcade—not a catalogue of charts.</p>
</div>
<button type="button" className="gg-surprise-button" onClick={surpriseMe}><Shuffle size={16} /> Surprise me</button>
</div>

<ArcadeProgress progress={progress} level={level} levelProgress={levelProgress} achievements={achievements} dailyLab={dailyLab} onOpenDaily={() => selectExperiment(dailyLab.id)} />

<div className="gg-arcade-selector" role="tablist" aria-label="Playable experiments">
{EXPERIMENTS.map((experiment) => {
const Icon = experiment.icon;
const selected = experiment.id === active;
const visited = progress.visited.includes(experiment.id);
return (
<button
key={experiment.id}
type="button"
role="tab"
aria-selected={selected}
className={`gg-arcade-card gg-arcade-card-${experiment.accent} ${selected ? 'active' : ''}`}
onClick={() => selectExperiment(experiment.id, false)}
>
<button key={experiment.id} type="button" role="tab" aria-selected={selected} className={`gg-arcade-card gg-arcade-card-${experiment.accent} ${selected ? 'active' : ''} ${visited ? 'visited' : ''}`} onClick={() => selectExperiment(experiment.id, false)}>
<span className="gg-arcade-card-number">{experiment.number}</span>
<span className="gg-arcade-card-icon"><Icon size={22} /></span>
<strong>{experiment.title}</strong>
<small>{experiment.short}</small>
<p>{experiment.description}</p>
<em>{experiment.mechanic}</em>
<em>{visited ? 'Explored' : experiment.mechanic}</em>
</button>
);
})}
</div>

<div className="gg-arcade-stage" data-experiment={activeExperiment.id}>
<div className="gg-arcade-stage-topline">
<span><i /> Experiment {activeExperiment.number} loaded</span>
<strong>{activeExperiment.title}</strong>
</div>
<div className="gg-arcade-stage-topline"><span><i /> Experiment {activeExperiment.number} loaded</span><strong>{activeExperiment.title}</strong></div>
{active === 'attractor' ? <AttractorPlayground /> : null}
{active === 'fireflies' ? <FireflySyncLab /> : null}
{active === 'waves' ? <WaveInterferenceLab /> : null}
Expand All @@ -175,15 +105,15 @@ export function ExperimentArcade() {
{active === 'flock' ? <FlockMindLab /> : null}
{active === 'outbreak' ? <OutbreakZeroLab /> : null}
{active === 'pendulum' ? <ChaosTwinsLab /> : null}
{active === 'ants' ? <AntTrailLab /> : null}
{active === 'traffic' ? <TrafficTamerLab /> : null}
{active === 'life' ? <LifePainterLab /> : null}
{active === 'ecosystem' ? <EcosystemBalanceLab /> : null}
</div>

<div className="gg-arcade-lesson">
<span>A complete science arcade needs</span>
<strong>Immediate motion</strong>
<strong>A clear mission</strong>
<strong>Different interaction verbs</strong>
<strong>A visible model</strong>
<strong>A shareable challenge</strong>
<span>The retention layer now adds</span>
<strong>Daily missions</strong><strong>XP and levels</strong><strong>Visit streaks</strong><strong>Achievements</strong><strong>Twelve tactile mechanics</strong>
</div>
</section>
);
Expand Down
Loading
Loading