Initial commit

This commit is contained in:
grovedruid
2026-05-12 04:56:41 +02:00
commit 6ee8432c26
243 changed files with 14878 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { useGameState } from './hooks/useGameState';
import StartScreen from './components/StartScreen';
import GameScreen from './components/GameScreen';
export default function App() {
const game = useGameState();
if (game.phase === 'menu') {
return <StartScreen onStart={(mode) => game.startGame(1, mode)} />;
}
return <GameScreen game={game} />;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Player } from '../types';
interface Props {
players: Player[];
}
export default function AIPlayers({ players }: Props) {
return (
<div className="ai-players">
{players.map((p, i) => {
const isWinner = p.matchInfo && p.matchInfo.matched === p.matchInfo.total && p.matchInfo.total > 0;
return (
<div key={i} className={`ai-player ${isWinner ? 'ai-winner' : ''}`}>
<div className={`player-name ${isWinner ? 'winner-text' : ''}`}>{p.name}</div>
<div className="player-stats">Hand: {p.hand.length} | Deck: N/A</div>
<div className="player-stats">Projects: {p.projectZone.length}/3</div>
{p.matchInfo && (
<div className="player-stats match-info">
Match: {p.matchInfo.matched}/{p.matchInfo.total}
</div>
)}
<div className="player-stats backup-info">
{p.backupCard ? '[Backup: hidden]' : '[No backup]'}
</div>
</div>
);
})}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { GameAPI } from '../hooks/useGameState';
interface Props {
game: GameAPI;
}
export default function ActionBar({ game }: Props) {
const human = game.players[0];
const canPlay = game.selectedCard !== null &&
game.selectedCard < human.hand.length &&
!human.hasActed &&
game.phase === 'play_turn';
const canEndTurn = human.hasActed && game.phase === 'end_turn_prompt';
return (
<div className="action-bar">
{game.phase === 'replace' && (
<>
<button className="btn btn-accent btn-glow" onClick={() => game.handleReplace(true)} disabled={human.usedReplace}>
Replace!
</button>
<button className="btn" onClick={() => game.handleReplace(false)}>
Keep Hand
</button>
</>
)}
{(game.phase === 'play_turn' || game.phase === 'end_turn_prompt') && (
<>
<button className="btn btn-play" disabled={!canPlay} onClick={game.playCard}>
Play Card
</button>
<button className="btn btn-end" disabled={!canEndTurn} onClick={game.endTurn}>
End Turn
</button>
</>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { GameAPI } from '../hooks/useGameState';
import type { Player } from '../types';
import { getCardImage } from '../data/cards';
interface Props {
game: GameAPI;
player: Player;
}
export default function BackupChoicePanel({ game, player }: Props) {
const backup = player.backupCard;
if (!backup) return null;
const slots = player.projectZone;
return (
<div className="backup-choice-panel">
<div className="backup-header">Backup Project Card Available</div>
<p className="backup-hint">
You can replace one of your project cards with your backup card to improve your score.
</p>
<div className="backup-card-display">
<div className="flip-inner flip-revealed preview-scale">
<div className="flip-front"><div className="card-back-overlay" /></div>
<div className="flip-back">
<img className="card-face" src={getCardImage(backup)} alt={backup.name} />
</div>
</div>
<div className="backup-card-name">{backup.name}</div>
</div>
<p className="backup-hint">Replace which slot?</p>
<div className="backup-slot-grid">
{slots.map((c, i) => (
<button key={i} className="btn btn-small" onClick={() => game.applyBackupChoice(i)}>
Slot {i + 1}: {c.name}
</button>
))}
</div>
<div className="backup-actions">
<button className="btn btn-small" onClick={() => game.applyBackupChoice(undefined)}>
Skip keep current cards
</button>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
interface Props {
step: string | null;
}
export default function ChallengeRevealOverlay({ step }: Props) {
if (!step) return null;
const isNumber = step === '3' || step === '2' || step === '1';
return (
<div className="challenge-reveal-overlay">
<div className="challenge-reveal-inner">
{step === 'intro' && (
<div className="reveal-intro">
<span className="reveal-label">The Challenge is...</span>
<span className="reveal-dots"><span>.</span><span>.</span><span>.</span></span>
</div>
)}
{isNumber && (
<div className="reveal-count" key={step}>
{step}
</div>
)}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { Card, GamePhase } from '../types';
import { iconPath, AE_LABELS, MFL_LABELS, SDG_LABELS } from '../data/labels';
import { getCardImage } from '../data/cards';
interface Props {
challenge: Card | null;
phase: GamePhase;
animatingCardId: number | null;
}
function iconLabel(type: 'ae' | 'mfl' | 'sdg', n: number): string {
if (type === 'ae') return AE_LABELS[n - 1] || `AE${n}`;
if (type === 'mfl') return MFL_LABELS[n - 1] || `MFL${n}`;
return SDG_LABELS[n - 1] || `SDG${n}`;
}
export default function ChallengeZone({ challenge, phase, animatingCardId }: Props) {
if (!challenge) {
return (
<div className="challenge-zone">
<div className="zone-label">Global Challenge</div>
<div className="challenge-row">
<div className="card-slot">
<div className="card-back" />
</div>
</div>
</div>
);
}
const revealed = phase !== 'menu' && phase !== 'setup';
return (
<div className="challenge-zone">
<div className="zone-label">Global Challenge</div>
<div className="challenge-row">
<div className="card-slot">
{revealed ? (
<img
className={`card-face ${animatingCardId === challenge.id ? 'card-flip' : 'card-reveal'}`}
src={getCardImage(challenge)}
alt={challenge.name}
/>
) : (
<div className="card-back" />
)}
</div>
<div className="challenge-right">
<div className="icon-panel">
{revealed && (
<>
{challenge.ae.map(v => (
<img key={`ae-${v}`} className="icon-img icon-pulse" src={iconPath('ae', v)} alt={`AE${v}`} title={iconLabel('ae', v)} />
))}
{challenge.mfl.map(v => (
<img key={`mfl-${v}`} className="icon-img icon-pulse" src={iconPath('mfl', v)} alt={`MFL${v}`} title={iconLabel('mfl', v)} />
))}
{challenge.sdgs.map(v => (
<img key={`sdg-${v}`} className="icon-img icon-pulse" src={iconPath('sdg', v)} alt={`SDG${v}`} title={iconLabel('sdg', v)} />
))}
</>
)}
</div>
{revealed && (
<div className="challenge-description">
<span className="challenge-desc-label">This Challenge is about:</span>
<div className="challenge-desc-tags">
{challenge.ae.map(v => (
<span key={`tag-ae-${v}`} className="challenge-tag tag-ae">{iconLabel('ae', v)}</span>
))}
{challenge.mfl.map(v => (
<span key={`tag-mfl-${v}`} className="challenge-tag tag-mfl">{iconLabel('mfl', v)}</span>
))}
{challenge.sdgs.map(v => (
<span key={`tag-sdg-${v}`} className="challenge-tag tag-sdg">{iconLabel('sdg', v)}</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react';
import type { Player, MatchRecord, MatchMode } from '../types';
interface Props {
players: Player[];
collaborativeWin: boolean;
matchMode: MatchMode;
matchIndex: number;
matchHistory: MatchRecord[];
seriesWinner: number | null;
onBack: () => void;
onNextMatch: () => void;
}
export default function EndScreen({ players, collaborativeWin, matchMode, matchIndex, matchHistory, seriesWinner, onBack, onNextMatch }: Props) {
const [celebrate, setCelebrate] = useState(true);
const isMulti = matchMode !== 'single';
const sorted = [...players].sort((a, b) => b.points - a.points);
const maxPoints = sorted[0]?.points ?? 0;
useEffect(() => {
if (!collaborativeWin) return;
const t = setTimeout(() => setCelebrate(false), 4000);
return () => clearTimeout(t);
}, [collaborativeWin]);
const cumulativeScores = isMulti ? players.map((_, pi) =>
matchHistory.reduce((sum, m) => sum + (m.scores[pi] || 0), 0)
) : [];
const seriesOver = seriesWinner !== null || (matchMode === 'bo2' && matchHistory.length >= 2) || (matchMode === 'bo3' && matchHistory.length >= 3);
return (
<div className={`end-screen ${collaborativeWin && celebrate ? 'celebrating' : ''}`}>
{collaborativeWin && celebrate && (
<div className="celebration-overlay">
<div className="confetti-container">
{Array.from({ length: 40 }).map((_, i) => (
<div key={i} className="confetti-piece" style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
animationDuration: `${2 + Math.random() * 2}s`,
backgroundColor: ['#8bc34a','#cddc39','#ffd54f','#ef5350','#42a5f5','#ab47bc','#ff7043'][i % 7],
}} />
))}
</div>
<div className="celebration-text">
<h2 className="celebration-title">Collaboration!</h2>
<p className="celebration-message">
Congratulations you collaborated and put your projects together to solve the challenge!
</p>
</div>
</div>
)}
<h2 className="end-title">
{seriesOver ? 'Series Over' : isMulti ? `Match ${matchIndex + 1} Complete` : 'Game Over'}
</h2>
{isMulti && matchHistory.length > 0 && (
<div className="tracking-sheet">
<div className="tracking-title">Score Tracker</div>
<div className="tracking-grid">
<div className="tracking-header">
<span className="tracking-cell">Match</span>
{players.map((p, i) => (
<span key={i} className="tracking-cell">{p.name}</span>
))}
</div>
{matchHistory.map((m, mi) => (
<div key={mi} className={`tracking-row ${mi === matchIndex ? 'tracking-current' : ''}`}>
<span className="tracking-cell">#{mi + 1}</span>
{m.scores.map((s, si) => (
<span key={si} className={`tracking-cell ${s > 0 ? 'tracking-points' : ''}`}>{s}</span>
))}
</div>
))}
<div className="tracking-row tracking-total">
<span className="tracking-cell">Total</span>
{cumulativeScores.map((s, i) => (
<span key={i} className={`tracking-cell ${s > 0 ? 'tracking-points' : ''}`}>{s}</span>
))}
</div>
</div>
</div>
)}
{seriesOver && (
<div className="series-result">
{seriesWinner !== null ? `${players[seriesWinner].name} wins the series!` : 'The series ended in a draw.'}
</div>
)}
<div className="results-container">
{sorted.map((p, i) => {
const isWinner = p.points === maxPoints && p.points > 0;
const mi = p.matchInfo || { matched: 0, total: 0 };
const details = mi.matched === mi.total && mi.total > 0
? `Full match! ${mi.matched}/${mi.total} icons`
: `${mi.matched}/${mi.total} icons matched`;
return (
<div key={i} className={`result-card ${isWinner ? 'winner' : ''}`}>
<div className="result-rank">{i === 0 && maxPoints > 0 ? '🏆' : ''}</div>
<div className="result-name">{p.name}</div>
<div className="result-score">{p.points} point{p.points !== 1 ? 's' : ''}</div>
<div className="result-detail">{details}</div>
<div className="result-cards">
Cards: {p.projectZone.map(c => c.name).join(', ')}
</div>
</div>
);
})}
</div>
<div className="end-buttons">
{isMulti && !seriesOver && (
<button className="btn btn-accent btn-glow" onClick={onNextMatch}>
Next Match
</button>
)}
<button className="btn" onClick={onBack}>
{seriesOver || !isMulti ? 'Play Again' : 'Quit Series'}
</button>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { GameAPI } from '../hooks/useGameState';
import ChallengeZone from './ChallengeZone';
import ProjectZone from './ProjectZone';
import AIPlayers from './AIPlayers';
import HandZone from './HandZone';
import ActionBar from './ActionBar';
import TurnBanner from './TurnBanner';
import ChallengeRevealOverlay from './ChallengeRevealOverlay';
import EndScreen from './EndScreen';
import SuddenSolvePanel from './SuddenSolvePanel';
import BackupChoicePanel from './BackupChoicePanel';
import HowToPlay from './HowToPlay';
interface Props {
game: GameAPI;
}
export default function GameScreen({ game }: Props) {
if (game.phase === 'done' && game.showResults) {
return (
<EndScreen
players={game.players}
collaborativeWin={game.collaborativeWin}
matchMode={game.matchMode}
matchIndex={game.matchIndex}
matchHistory={game.matchHistory}
seriesWinner={game.seriesWinner}
onBack={game.backToMenu}
onNextMatch={game.nextMatch}
/>
);
}
return (
<div className="game-screen">
<TurnBanner message={game.turnBanner} />
<ChallengeRevealOverlay step={game.challengeReveal} />
<div className="game-header">
<div className="header-left">
<span className="round-badge">
{game.round >= 1 ? `Year ${game.round} / 3` : 'Setup'}
</span>
<span className="deck-count">Deck: {game.deck.length}</span>
</div>
</div>
<ChallengeZone
challenge={game.challenge}
phase={game.phase}
animatingCardId={game.animatingCardId}
/>
<div className="mid-row">
<ProjectZone
player={game.players[0]}
phase={game.phase}
animatingCardId={game.animatingCardId}
/>
<AIPlayers players={game.players.slice(1)} />
</div>
<div className="hand-zone">
<HowToPlay phase={game.phase} />
<img className="hand-zone-logo hand-zone-logo-left" src="/assets/fsys.png" alt="" />
<img className="hand-zone-logo hand-zone-logo-right" src="/assets/cgiar.png" alt="" />
<div className="zone-label">Your Hand</div>
{game.phase === 'sudden_solve' ? (
<SuddenSolvePanel
game={game}
player={game.players[0]}
/>
) : game.phase === 'backup_choice' ? (
<BackupChoicePanel
game={game}
player={game.players[0]}
/>
) : (
<HandZone
cards={game.players[0].hand}
selectedCard={game.selectedCard}
hasActed={game.players[0].hasActed}
phase={game.phase}
onSelect={game.selectCard}
/>
)}
</div>
<ActionBar game={game} />
<div className={`status-bar ${game.statusClass}`}>
{game.statusMessage}
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useState, useRef, useEffect } from 'react';
import type { Card, GamePhase } from '../types';
import { getCardImage } from '../data/cards';
interface Props {
cards: Card[];
selectedCard: number | null;
hasActed: boolean;
phase: GamePhase;
onSelect: (index: number | null) => void;
}
export default function HandZone({ cards, selectedCard, hasActed, phase, onSelect }: Props) {
const [previewCard, setPreviewCard] = useState<Card | null>(null);
const [tapPreview, setTapPreview] = useState<number | null>(null);
const isCoarse = useRef(false);
useEffect(() => {
isCoarse.current = window.matchMedia('(pointer: coarse)').matches;
}, []);
const canSelect = (phase === 'play_turn' && !hasActed) || phase === 'sudden_solve';
const isDimmed = hasActed && phase !== 'replace';
if (phase === 'done' || phase === 'scoring') {
return <span className="hand-placeholder">Game over see results</span>;
}
return (
<>
<div className="hand-row">
{cards.map((card, i) => (
<div
key={card.id}
className={`card-slot hand-card ${selectedCard === i ? 'selected' : ''} ${canSelect ? 'clickable' : ''} ${isDimmed ? 'disabled' : ''} ${tapPreview === i ? 'tapped-preview' : ''}`}
onClick={() => {
if (!canSelect) return;
onSelect(selectedCard === i ? null : i);
}}
onMouseEnter={() => setPreviewCard(card)}
onMouseLeave={() => setPreviewCard(null)}
onTouchStart={() => {
if (isCoarse.current) setTapPreview(i);
}}
onTouchEnd={() => {
if (isCoarse.current) {
setTimeout(() => setTapPreview(null), 800);
}
}}
>
<img className="card-face" src={getCardImage(card)} alt={card.name} />
{selectedCard === i && <div className="card-glow" />}
</div>
))}
</div>
{(previewCard || (tapPreview !== null && cards[tapPreview])) && (
<div className="card-preview-bar">
<span className="preview-name">{(tapPreview !== null ? cards[tapPreview] : previewCard)!.name}</span>
<span className="preview-desc">{(tapPreview !== null ? cards[tapPreview] : previewCard)!.action_text}</span>
</div>
)}
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { useState } from 'react';
import type { GamePhase } from '../types';
interface Props {
phase: GamePhase;
}
const GUIDES: Record<string, string> = {
replace: "Look at the Challenge icons on the top. If just a few or no icons in your hand cards match the challenge icons, you can draw 5 new cards. Click Replace. If you are happy with your hand click Keep hand.",
play_turn: "Each year, you play out 1 project card. Try to match as many icons with the challenge icons as possible. Choose a card wisely, then click Play Card, then End turn (this is the same for all 3 years)",
end_turn_prompt: "Each year, you play out 1 project card. Try to match as many icons with the challenge icons as possible. Choose a card wisely, then click Play Card, then End turn (this is the same for all 3 years)",
};
export default function HowToPlay({ phase }: Props) {
const [open, setOpen] = useState(false);
if (phase === 'menu' || phase === 'setup' || phase === 'done' || phase === 'scoring') return null;
const text = GUIDES[phase] || GUIDES.play_turn;
return (
<div className={`how-to-play ${open ? 'how-to-play-open' : ''}`}>
<button className="how-to-play-toggle" onClick={() => setOpen(!open)} title="How to play">
<span>How to play</span>
</button>
{open && (
<div className="how-to-play-panel">
<p className="how-to-play-text">{text}</p>
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { Player, GamePhase } from '../types';
import { getCardImage } from '../data/cards';
interface Props {
player: Player;
phase: GamePhase;
animatingCardId: number | null;
}
export default function ProjectZone({ player, phase, animatingCardId }: Props) {
const revealed = phase === 'sudden_solve' || phase === 'scoring' || phase === 'done' || phase === 'backup_choice';
const hideFaces = !revealed;
return (
<div className="project-zone">
<div className="zone-label">Your Projects</div>
<div className="project-row">
{[0, 1, 2].map(i => (
<div
key={i}
className={`card-slot project-slot ${i < player.projectZone.length ? 'filled flip-card' : ''}
${animatingCardId && player.projectZone.find(c => c.id === animatingCardId && player.projectZone.indexOf(c) === i) ? 'animating-in' : ''}`}
>
{i < player.projectZone.length ? (
<div className={`flip-inner ${hideFaces ? 'flip-enabled' : 'flip-revealed'} ${phase === 'backup_choice' ? 'preview-scale' : ''} ${animatingCardId === player.projectZone[i].id ? 'card-slide-in' : ''}`}>
<div className="flip-front">
<div className="card-back-overlay" />
</div>
<div className="flip-back">
<img className="card-face" src={getCardImage(player.projectZone[i])} alt={player.projectZone[i].name} />
</div>
</div>
) : (
<span className="slot-label">Project {i + 1}</span>
)}
</div>
))}
<div className="project-row-sep" />
<div className={`card-slot project-slot ${player.backupCard ? 'filled' : ''}`}>
{player.backupCard ? (
<div className={`flip-inner ${revealed ? 'flip-revealed' : ''} ${phase === 'backup_choice' ? 'preview-scale' : ''}`}>
<div className="flip-front">
<div className="card-back-overlay" />
</div>
<div className="flip-back">
<img className="card-face" src={getCardImage(player.backupCard)} alt={player.backupCard.name} />
</div>
</div>
) : (
<span className="slot-label">backup</span>
)}
</div>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
import type { MatchMode } from '../types';
interface Props {
onStart: (mode: MatchMode) => void;
}
const MODES: { key: MatchMode; label: string }[] = [
{ key: 'single', label: '1 Match' },
{ key: 'bo2', label: 'Best of 2' },
{ key: 'bo3', label: 'Best of 3' },
];
export default function StartScreen({ onStart }: Props) {
const [mode, setMode] = useState<MatchMode>('single');
return (
<div className="start-screen">
<div className="start-card">
<img className="start-logo" src="/assets/fsys.png" alt="fsys" />
<h1>Collectible Card Game</h1>
<p className="subtitle">Coopetitive Match You vs AI</p>
<p className="intro-text">
You are<br />an international Organization.<br />
You have 3 years and 3 projects to solve a global challenge.
</p>
<div className="start-form">
<div className="mode-selector">
<span className="mode-label">Series:</span>
<div className="mode-buttons">
{MODES.map(m => (
<button
key={m.key}
className={`btn btn-small ${mode === m.key ? 'btn-accent' : ''}`}
onClick={() => setMode(m.key)}
>
{m.label}
</button>
))}
</div>
</div>
<button className="btn btn-glow" onClick={() => onStart(mode)}>
New Game
</button>
</div>
</div>
<div className="diy-section">
<p className="diy-text">Want to print and play real cards?</p>
<a href="https://www.fsysgame.org/fsys-ccg-diy-kit/" target="_blank" rel="noopener noreferrer">
<img className="diy-image" src="/assets/diy.jpg" alt="DIY card kit" />
</a>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import type { GameAPI } from '../hooks/useGameState';
import type { Player } from '../types';
import { getCardImage } from '../data/cards';
interface Props {
game: GameAPI;
player: Player;
}
export default function SuddenSolvePanel({ game, player }: Props) {
const [step, setStep] = useState<'choose' | 'backup' | 'backup-slot' | 'done'>('choose');
const handlePlayCard = (index: number) => {
game.suddenSolvePlayCard(index);
if (player.backupCard) {
setStep('backup');
} else {
setStep('done');
}
};
const handleSkipPlay = () => {
if (player.backupCard) {
setStep('backup');
} else {
game.suddenSolveSkipBackup();
setStep('done');
}
};
if (game.animatingCardId) {
return <div className="ss-animating">Playing card...</div>;
}
const noCardsInHand = player.hand.length === 0;
if (step === 'choose' && !noCardsInHand) {
return (
<div className="sudden-solve-panel">
<div className="ss-header"> Sudden Solve! Final Chance </div>
<div className="ss-hint">Select a card to play:</div>
<div className="ss-card-row">
{player.hand.map((card, i) => (
<div
key={card.id}
className="card-slot hand-card clickable"
onClick={() => handlePlayCard(i)}
>
<img className="card-face" src={getCardImage(card)} alt={card.name} />
</div>
))}
</div>
<button className="btn" onClick={handleSkipPlay}>
{player.backupCard ? 'Skip Card — Use Backup?' : 'Skip'}
</button>
</div>
);
}
const showBackup = player.backupCard && (step === 'backup' || (noCardsInHand && step === 'choose'));
if (showBackup) {
return (
<div className="sudden-solve-panel">
<div className="ss-header"> Sudden Solve! Final Chance </div>
<div className="ss-backup-choice">
<p>Use your Backup Project Card?</p>
<div className="ss-buttons">
<button className="btn btn-accent" onClick={() => setStep('backup-slot')}>
Yes, use backup
</button>
<button className="btn" onClick={() => { game.suddenSolveSkipBackup(); setStep('done'); }}>
No
</button>
</div>
</div>
</div>
);
}
if (step === 'backup-slot' && player.backupCard) {
if (player.projectZone.length === 0) {
game.suddenSolveUseBackup(-1);
return <div className="ss-animating">Adding backup...</div>;
}
return (
<div className="sudden-solve-panel">
<div className="ss-header"> Sudden Solve! Final Chance </div>
<div className="ss-backup-slot-choice">
<p>Replace which project slot?</p>
<div className="ss-slot-grid">
{player.projectZone.map((c, i) => (
<button key={i} className="btn btn-small" onClick={() => game.suddenSolveUseBackup(i)}>
Slot {i + 1}: {c.name}
</button>
))}
<button className="btn btn-small btn-accent" onClick={() => game.suddenSolveUseBackup(-1)}>
Add as new slot
</button>
</div>
</div>
</div>
);
}
return <div className="ss-animating">Processing...</div>;
}
+15
View File
@@ -0,0 +1,15 @@
interface Props {
message: string;
}
export default function TurnBanner({ message }: Props) {
if (!message) return null;
return (
<div className="turn-banner">
<div className="turn-banner-inner">
{message}
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Card } from '../types';
export const CARDS: Card[] = [
{id:1,name:"Gift of Water",action_text:"Make someone happy and bring fresh them water from the well, tank, or tap today.",description:"Sharing water reminds us that clean water is a treasure that everyone in our community should have.",ae:[9],mfl:[5],sdgs:[3,6]},
{id:2,name:"Grow Greens",action_text:"Tasty sprouts & herbs. Get seeds, plant in soil, water, care. Grow and snip fresh greens.",description:"Growing your own food is a superpower that makes you more independent and gives you the freshest snacks.",ae:[1,9],mfl:[2,3],sdgs:[2,3]},
{id:3,name:"Fine Dining Compost Dish",action_text:"Style food scraps for composting like a fine dining dish and offer them to Mother Earth.",description:"When we compost, we are showing respect to 'Mother Earth' by giving back the energy we didn't use.",ae:[1,2],mfl:[1,9],sdgs:[12,13]},
{id:4,name:"Buzzing with Wisdom",action_text:"Learn about bees their lives, their rhythms, and their ancient connection to plants and ecosystems. Memorize one fact and share it with others.",description:"Understanding bees is important because they are the 'connectors' that help plants grow and keep our world alive.",ae:[4,5],mfl:[5,7],sdgs:[13,15]},
{id:5,name:"Baking Traditional Bread",action_text:"Traditional bread or flatbread is full of life force and taste. Try using flour of indigenous or other nutritious crops to bake your favorite bread this week.",description:"Using local crops keeps our traditions alive and fills our bodies with the 'life force' of our own land.",ae:[8,9],mfl:[3,6],sdgs:[2,3]},
{id:6,name:"Mixed Waste-monster",action_text:"Food waste all mixed together is useless. Identify the different waste types, sort them, and find ways to reduce & recycle this week.",description:"Sorting trash stops it from becoming a 'monster' and turns it into useful things we can use again.",ae:[1,3],"mfl":[5,9],sdgs:[12,13]},
{id:7,name:"Ingredient Detective",action_text:"Flip the package, read every ingredient, learn what it is and where it comes from.",description:"Being a detective helps you choose food that is good for your body and fair to the people who made it.",ae:[8,13],mfl:[3,10],sdgs:[3,12]},
{id:8,name:"Mindful Eating",action_text:"Smell, touch, feel, savor each bite consciously. Actively appreciate how food nourishes and sustains us.",description:"Slowing down to enjoy food helps us appreciate all the hard work nature and farmers did to feed us.",ae:[9,11],mfl:[3],sdgs:[3,12]},
{id:9,name:"Fermentastic",action_text:"Discover the art of fermentation a natural and cheap way to preserve and enrich ingredients. Learn the basics and set up your first jar.",description:"Fermenting is a clever, low-cost way to make food last longer and become even better for your tummy.",ae:[1,7],mfl:[3,8],sdgs:[3,12]},
{id:10,name:"Tree Planter",action_text:"Plant a tree in your compound, school, or in degraded spaces. Join hands with others to learn more and have fun.",description:"Planting a tree is a gift to the future; it helps fix the land and gives the Earth a chance to breathe.",ae:[5,8],mfl:[4,5],sdgs:[7,13]},
{id:11,name:"Seed Scout",action_text:"Scout for seeds in your surroundings. Where can you get them? Shops, farms, schools, neighbors? Then obtain some and start saving seeds.",description:"Learning where to find and save seeds is the first step to making sure your community always has enough food to grow.",ae:[5,12],mfl:[6],sdgs:[1,2]},
{id:12,name:"Natural Spritz",action_text:"Spraying chemicals kills pests but harms the environment, beneficial insects, and health. Prepare and use a natural biopesticide and see how it works wonders and protects people and the environment.",description:"Using natural sprays proves we can protect our gardens without hurting ourselves or the helpful bugs.",ae:[2,4],mfl:[1,2],sdgs:[9,12]},
{id:13,name:"Dashboard Designer",action_text:"Design and paint a 'data dashboard' which shows information about something that influences how well your family's crops grow (which seed, which management, which inputs, etc.).",description:"Tracking your 'data' helps you become a smarter farmer because you can see exactly what makes your plants happy.",ae:[8],mfl:[10],sdgs:[8,9]},
{id:14,name:"Onion Bodyguard",action_text:"Ever heard of the superpower of onions? Plant some onions between other plants and observe their protection pester powers.",description:"This shows how different plants can look out for each other, using 'bodyguard' plants to keep pests away naturally.",ae:[2,6],mfl:[1,5],sdgs:[13,15]},
{id:15,name:"Local Investor",action_text:"Buy food from a local farm or market today instead of going to a supermarket!",description:"Buying from neighbors keeps your community's money close to home and supports local families.",ae:[7,9],mfl:[3,8],sdgs:[1,11]},
{id:16,name:"Poster Designer",action_text:"Create posters about why everyone has the right to be included in farming, owning land, and attending community meetings.",description:"Using art to speak up about land rights helps everyone understand that they have a fair place in the world of farming",ae:[12,13],mfl:[7,11],sdgs:[5,10]},
{id:17,name:"Change-Maker",action_text:"Find a topic you are passionate about, join a group that cares about the same, and contribute to making a difference!",description:"Joining a group shows that when we work together on things we care about, our small actions turn into big changes.",ae:[8,13],mfl:[11,12],sdgs:[16,17]},
{id:18,name:"Waste Remover",action_text:"Clean up, sort, and recycle the waste you find in your surroundings to help keep animals, plants, land, and water healthy.",description:"Cleaning up trash keeps our land and water healthy for the animals and plants we share our home with.",ae:[1,12],mfl:[5],sdgs:[6,13]},
{id:19,name:"Manure Maker",action_text:"Learn how to 'cool down' animal waste before adding it to your compost pile to create super-food for your plants!",description:"Turning waste into 'super-food' for plants is the best way to recycle energy back into the soil.",ae:[3,6],mfl:[1,9],sdgs:[12,15]},
{id:20,name:"Mixed Garden",action_text:"Plant maize, beans, leafy greens, forages, and more. Learn about companion planting and that a mixed garden means more food, more soil nutrients, and a healthier family.",description:"A garden with many types of plants is stronger, healthier, and provides a diversity of vitamins for your family.",ae:[4,5],mfl:[2,5],sdgs:[2,15]},
{id:21,name:"Ash Bug Blaster",action_text:"Use wood ash to fight bugs naturally. No chemicals needed — ash is free, safe, and it works!",description:"It's important that we can protect our food using simple, free things from our own homes, like wood ash.",ae:[2,6],mfl:[1,5],sdgs:[12,15]},
{id:22,name:"Dry & Store",action_text:"Dry your seeds and store them safely. Dry seeds last longer and grow better — patience now means abundance later.",description:"Proper drying and storage is a smart way to protect your seeds so they stay healthy and ready to sprout when the next season comes.",ae:[5,9],mfl:[6],sdgs:[2,15]},
{id:23,name:"Seed Distributor",action_text:"Collect ripe fruits and share the seeds with local farmers to spread the harvest further.",description:"Sharing seeds with other farmers is a great way to spread the harvest and make sure everyone in the village can grow good food.",ae:[10,11],mfl:[6,8],sdgs:[12,15]},
{id:24,name:"River Cleaners",action_text:"Organize a river clean-up day. A clean river means clean water for animals, plants, and the whole community.",description:"A clean river is like a healthy heartbeat for a community, providing safe water for everyone",ae:[8,12],mfl:[11,12],sdgs:[6,14]},
{id:25,name:"Garden Documenter",action_text:"Use a smartphone or tablet camera. Take pictures of all the plants growing in the school garden in a month. Create a slideshow and present to your classmates.",description:"Using cameras and technology to track your garden helps you see the 'big picture' of how nature changes over time.",ae:[8],mfl:[10],sdgs:[4,9]},
{id:26,name:"Local Variety Guardian",action_text:"Save seeds from traditional crops. Local varieties are often stronger, more nutritious, and part of your community's food heritage — protect them.",description:"Saving seeds from traditional crops protects your community's history and keeps local plants strong and healthy.",ae:[5,9],mfl:[6],sdgs:[2,3]},
{id:27,name:"Nature Regenerator",action_text:"Let nature grow back. Protect young trees and shrubs that appear naturally — farmer-managed regeneration works with nature, not against it.",description:"Sometimes the best way to help nature is to simply protect what is already trying to grow back on its own.",ae:[5,6],mfl:[4,5],sdgs:[7,13]},
{id:28,name:"Biodiversity Steward",action_text:"Protect wild places. Some land should stay free from human activity — forests, wetlands, and riverbanks are home to animals and plants that we all and the earth need.",description:"Protecting wild places ensures that nature has its own safe space to thrive without being disturbed.",ae:[4,5],mfl:[5,7],sdgs:[13,14]},
{id:29,name:"Land Negotiator",action_text:"When different people want to use the same land for different things, think together about strategies that will help develop a plan that works for everyone.",description:"Learning how to talk through disagreements about land helps create a plan that is fair and peaceful for everyone.",ae:[12,13],mfl:[7,11],sdgs:[5,16]},
{id:30,name:"TJD Booster",action_text:"If you played out 5 cards and action missions this week, reward yourself the World Class Changemaker achievement",description:"Celebrating your hard work as a 'Changemaker' gives you the energy and pride to keep helping your community and the planet.",ae:[13],mfl:[11],sdgs:[17]},
{id:31,name:"Kitchen Garden",action_text:"Plant a fruit, vegetable, or herb outside your home. Water every day and watch it grow. Fresh food right at your door—save money and eat well!",description:"Having a garden right outside your door means you always have healthy food that costs nothing but a little care.",ae:[2,3],mfl:[2,3],sdgs:[1,11]},
{id:32,name:"Balanced Plate Builder",action_text:"Look at what you eat today and sort your food into groups: Carbs, proteins, dairy, fruits & vegetables, and fats. Find out what each group does for your body. Tomorrow, try to build a more balanced plate, to help your body grow strong and stay healthy!",description:"Learning how to balance your plate is the key to building a body that is strong enough to change the world.",ae:[9],mfl:[3],sdgs:[2,3]},
{id:33,name:"Indigenous Tree Lover",action_text:"Ask a family or community member about an indigenous tree in your area and all its benefits. Ask about methods to protect them, find an indigenous tree and out your new skills into practice.",description:"Protecting local trees using wisdom from your family keeps the balance of nature in your area healthy.",ae:[5,6],mfl:[4,5],sdgs:[13,15]},
{id:34,name:"Cover Crop Protector",action_text:"Cover crops are natural protectors. They protect from the sun, preserve moisture, and keep the soil alive. Plant a cover crop in your school or home garden, such as pumpkins, 9 or cowpeas.",description:"These plants act like a 'living blanket' for the soil, keeping it cool, wet, and full of life.",ae:[3,6],mfl:[1,2],sdgs:[13,15]},
{id:35,name:"Local Food Producer",action_text:"Find one food your community usually buys from far away (e.g., flour, fruits, oil, jam). Make a plan: How could your school or family produce this locally? What would you need? Who would you sell to?",description:"Making things locally reduces the need for big trucks and long trips, which is better for the planet.",ae:[7,11],mfl:[8,11],sdgs:[8,12]},
{id:36,name:"Market Price Checker",action_text:"Visit a local market and check the price of 3 vegetables. Compare prices at the market vs. at a supermarket. Discuss with your friend where you and the producers get a fairer deal, and why.",description:"Checking prices helps you understand how to get a fair deal for both the person growing the food and the person buying it.",ae:[10,11],mfl:[8,10],sdgs:[1,8]},
{id:37,name:"Community Meeting",action_text:"When people learn and decide together, everyone grows stronger. Plan a class or community meeting about a food or farming topic. Ensure everyone has a chance to speak. Write down the key ideas and decisions.",description:"Making sure everyone gets a chance to speak at meetings ensures that big decisions are fair and include everyone's best ideas.",ae:[8,13],mfl:[11],sdgs:[5,10]},
{id:38,name:"Jar Farm Product Marketer",action_text:"Grow sprouts in a jar for a week, invent a product name, design a label and 'market' your healthy food ingredient to family, friends and neighbors.",description:"Learning how to label and 'sell' healthy food teaches you the business skills needed to be a successful green entrepreneur.",ae:[2,7],mfl:[2,8],sdgs:[1,9]},
{id:39,name:"Food Donation Hero",action_text:"Everybody should have access to healthy and yummy food. If you can, share some food with someone who needs it today. It can be small, every act matters.",description:"Sharing food ensures that 'healthy and yummy' meals aren't a luxury, but something everyone can enjoy.",ae:[10],mfl:[3],sdgs:[2,10]},
{id:40,name:"Windbreak Warrior",action_text:"Trees are powerful protectors for people and farms. Stand under a tree and feel what it does. Think about how it blocks wind, gives shade, and protects both you and your crops?",description:"Trees are like giant shields that protect our homes and crops from being damaged by the wind and sun.",ae:[5,6],mfl:[4,7],sdgs:[7,15]},
{id:41,name:"Seed Champion",action_text:"Every person who saves seeds makes the whole community stronger, and more independent. Help someone understand why saving and sharing seeds matters.",description:"When you teach others to save seeds, you help your whole community become more independent and powerful.",ae:[5,10],mfl:[6,12],sdgs:[1,10]},
{id:42,name:"Fruit & Tree Team",action_text:"Nature works together. Try planting or finding a climbing fruit next to a tree. Watch how the tree supports the fruit, and how they live together happily.",description:"This shows how nature loves teamwork; trees can provide the 'ladder' for fruits to climb and grow better.",ae:[5,6],mfl:[4,7],sdgs:[13,15]},
{id:43,name:"Manure Cycle Drawer",action_text:"Animals make manure. The nutrients in the manure feed crops. Crops feed people. Draw this farm nutrient cycle and show how everything is connected!",description:"Drawing this cycle helps us see how animals, plants, and people are all part of one big, connected family.",ae:[4,6],mfl:[1,7],sdgs:[13,15]},
{id:44,name:"Animal Hero",action_text:"Think about a farm with animals, crops, trees and people together. How do animals help the farm? Tell a friend about all the ways!",description:"Sharing the story of how animals help the farm helps others see that every creature has an important job to do in nature.",ae:[4,6],mfl:[1,7],sdgs:[15]},
{id:45,name:"Land Magic",action_text:"One piece of land can do so many things! Choose a piece of land (home, school, or a nearby farm), and list everything it already provides for people and nature. Now think about 3 more things it could do. Draw a map and share it with your friends.",description:"Mapping all the things one piece of land can do helps us discover new ways to grow food and protect nature at the same time.",ae:[5,6],mfl:[7],sdgs:[13,15]},
{id:46,name:"Local Chicken Lover",action_text:"Find a member of the local community who raises and sells local chickens. Ask them what they love about them!",description:"Supporting local chicken farmers helps keep your community's economy strong and respects animals that belong in your environment.",ae:[6,7],mfl:[1,8],sdgs:[1,8]},
{id:47,name:"Food Dryer Explorer",action_text:"Choose a fruit or vegetable that your home sometimes has too much of (e.g. mangoes, tomatoes). Find out how to dry and preserve it, then try it out!",description:"Drying food is a smart way to make sure that 'too much' food today becomes a great snack for tomorrow.",ae:[1,7],mfl:[3,9],sdgs:[9,12]},
{id:48,name:"Scrap-tastic Animal Feeder",action_text:"Turn waste into feeds — nothing goes to waste! Collect food scraps and give them to animals (like chickens). What scraps can they eat? What should not be fed? Write down a scrap-tastic menu for them.",description:"Feeding animals the right scraps means nothing is ever wasted on a smart, circular farm.",ae:[1,4],mfl:[1,9],sdgs:[11,15]},
{id:49,name:"Leftover Chef",action_text:"Say no to food waste! Use yesterday's leftovers to make a meal. How can you make it tasty again?",description:"Being a leftover chef is a creative way to say 'no' to waste and 'yes' to delicious, recycled meals.",ae:[1,9],mfl:[3,9],sdgs:[2,12]},
{id:50,name:"Weather Tracker Checker",action_text:"Check the weather each day for a week using a phone or computer. Tip: Planting at the right time saves crops and makes your harvest bigger!",description:"Checking the weather helps you become a 'science-based' farmer, so you can plant at the perfect time to get a big harvest.",ae:[8,12],mfl:[10],sdgs:[4,9]},
{id:51,name:"Farm Record Keeper",action_text:"Good records help you understand your farm better. Record information about 3 plants in your farm (at home or at school): planting time, management, pests, changes in color, growth etc. Think about the data you need to make good farming decision.",description:"Writing down facts about your plants helps you understand what they need to grow best, making you a much smarter farmer.",ae:[8,12],mfl:[10],sdgs:[4,9]},
{id:52,name:"The Water Fertilizers",action_text:"Did you know that fish are little wizards? They make for yummy meals, but also fertilize the water they live in that can be reused to water your crops. Discuss with your friends if you can set up a fish pond at school.",description:"This shows how we can use nature's own 'magic' (like fish) to help our food grow without needing chemicals",ae:[6,7],mfl:[1,7],sdgs:[1,14]},
{id:53,name:"Small Farm, Big Voice",action_text:"Even small farms matter! Everything is connected. Challenge: Roleplay a farmer at a community meeting and speak up for sustainable land use. What would you say?",description:"Even if a farm is small, speaking up for sustainable land use is a powerful way to protect the future of food.",ae:[12,13],mfl:[7,11],sdgs:[5,10]},
{id:54,name:"Nature Educator",action_text:"When people understand, they act. Teach someone in your community why and how to protect nature, save forests, animals, and rivers!",description:"Teaching others how to protect forests and rivers spreads a 'green message' that can save the environment for everyone.",ae:[8,13],mfl:[5,12],sdgs:[10,15]},
{id:55,name:"Teamwork Harvest",action_text:"Plant and grow something with 1 or 2 friends. Teamwork makes your work easier and your harvest bigger. Many hands, full baskets — and full hearts!",description:"Doing things with friends makes the work lighter and the celebration of the harvest much bigger!",ae:[8,13],mfl:[2,11],sdgs:[8,15]},
{id:56,name:"Sky Garden",action_text:"Food can grow almost anywhere. Try growing upwards by planting in a hanging basket! More food with less space. Your garden can reach the sky!",description:"Growing upwards proves that you don't need a huge farm to be a successful gardener—you just need a little creativity!",ae:[7,9],mfl:[2,7],sdgs:[9,10]},
{id:57,name:"Write for Change",action_text:"Write to a local leader about a food system issue you care about. Be precise, present evidence, make suggestions for change, offer support.",description:"Writing to leaders with real evidence shows them that young people have the power and the facts to help change the world.",ae:[12,13],mfl:[11,12],sdgs:[10,16]},
{id:58,name:"Ancient Wisdom Defender",action_text:"Old knowledge is gold! Talk to an elder about farming and food. Learn from their stories!",description:"Talking to elders is like finding a treasure chest of secrets that can help us farm better today.",ae:[8,9],mfl:[11,12],sdgs:[5,10]},
{id:59,name:"Seedling Protector",action_text:"Visit a tree nursery. Learn how you can join hands to grow a tree step-by-step from selecting a good seed to planting, and helping it grow.",description:"Learning how to care for tiny trees in a nursery ensures they grow up strong enough to protect the environment.",ae:[6,12],mfl:[4,11],sdgs:[7,15]},
{id:60,name:"Cooperative Builder",action_text:"Every voice counts — but there is power in numbers. When farmers work together and sell as a group, they earn more and waste less. Find ways to improve access to healthy food in your area.",description:"When we work together as a group, we have a much louder voice and can solve bigger problems.",ae:[10,11],mfl:[8,9],sdgs:[10,11]}
];
export const CARD_IMG: (string | null)[] = [
null,
"1-gift-of-water-action.png","2-grow-greens-action.png","3-fine-dining-compost-dish-action.png",
"4-buzzing-with-wisdom-action.png","5-baking-flatbread.png","6-mixed-wastemonster-action.png",
"7-ingredient-detective-action.png","8-mindful-eating-action.png","9-fermentastic-action.png",
"10-tree-planter.png","11-seed-scout.png","12-natural-spritz.png",
"13-dashboard-designer.png","14-onion-bodyguard.png","15-local-investor.png",
"16-poster-designer.png","17-change-maker.png","18-waste-remover.png",
"19-manure-maker.png","20-mixed-garden.png","21-ash-bug-blaster.png",
"22-dry-and-store.png","23-seed-distributor.png","24-river-cleaners.png",
"25-garden-documenter.png","26-local-variety-guardian.png","27-nature-regenerator.png",
"28-biodiversity-steward.png","29-land-negotiator.png","30-tjd-booster-action.png",
"31-kitchen-garden.png","32-balanced-plate-builder.png","33-indigenous-tree-lover.png",
"34-cover-crop-protector.png","35-local-food-producer.png","36-market-price-checker.png",
"37-community-meeting.png","38-jar-farm-product-marketer.png","39-food-donation-hero.png",
"40-windbreak-warrior.png","41-seed-champion.png","42-fruit-and-tree-team.png",
"43-manure-cycle-drawer.png","44-animal-hero.png","45-land-magic.png",
"46-local-chicken-lover.png","47-food-dryer-explorer.png","48-scrap-tastic-animal-feeder.png",
"49-leftover-chef.png","50-weather-tracker-checker.png","51-farm-record-keeper.png",
"52-the-water-fertilizers.png","53-small-farm-big-voice.png","54-nature-educator.png",
"55-teamwork-harvest.png","56-sky-garden.png","57-be-the-change-action.png",
"58-ancient-wisdom-defender-action.png","59-seedling-protector-action.png","60-cooperative-builder-action.png"
];
export function getCardImage(card: Card): string {
const f = CARD_IMG[card.id];
return f ? `/assets/cards/${f}` : '';
}
+27
View File
@@ -0,0 +1,27 @@
export const AE_LABELS = [
"AE1: Recycling","AE2: Input reduction","AE3: Soil health","AE4: Animal health",
"AE5: Biodiversity","AE6: Synergy","AE7: Economic diversification",
"AE8: Co-creation of knowledge","AE9: Social values & diets",
"AE10: Fairness","AE11: Connectivity","AE12: Land & natural governance",
"AE13: Participation"
];
export const MFL_LABELS = [
"MFL1: Conservation","MFL2: Production","MFL3: Consumption","MFL4: Trees",
"MFL5: Water","MFL6: Seeds","MFL7: Ecosystems","MFL8: Markets",
"MFL9: Waste","MFL10: Data","MFL11: Governance","MFL12: Education"
];
export const SDG_LABELS = [
"SDG1: No poverty","SDG2: Zero hunger","SDG3: Good health","SDG4: Quality education",
"SDG5: Gender equality","SDG6: Clean water","SDG7: Clean energy","SDG8: Decent work",
"SDG9: Industry & innovation","SDG10: Reduced inequalities","SDG11: Sustainable cities",
"SDG12: Responsible consumption","SDG13: Climate action","SDG14: Life below water",
"SDG15: Life on land","SDG16: Peace & justice","SDG17: Partnerships"
];
export function iconPath(type: 'ae' | 'mfl' | 'sdg', n: number): string {
const folderMap = { ae: 'ae', mfl: 'MFL', sdg: 'SDG' } as const;
const extMap = { ae: 'svg', mfl: 'png', sdg: 'png' } as const;
return `/assets/icons/${folderMap[type]}/${type.toUpperCase()}-${n}.${extMap[type]}`;
}
+566
View File
@@ -0,0 +1,566 @@
import { useState, useCallback, useRef } from 'react';
import type { Card, Player, GamePhase, MatchInfo, MatchRecord, MatchMode } from '../types';
import { CARDS } from '../data/cards';
function shuffle<T>(a: T[]): T[] {
const arr = [...a];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function matchCount(playerCards: Card[], challenge: Card): MatchInfo {
const ae = new Set<number>(), mfl = new Set<number>(), sdgs = new Set<number>();
for (const c of playerCards) {
c.ae.forEach(v => ae.add(v));
c.mfl.forEach(v => mfl.add(v));
c.sdgs.forEach(v => sdgs.add(v));
}
const cae = new Set(challenge.ae), cmfl = new Set(challenge.mfl), csdg = new Set(challenge.sdgs);
let m = 0, t = 0;
cae.forEach(v => { t++; if (ae.has(v)) m++; });
cmfl.forEach(v => { t++; if (mfl.has(v)) m++; });
csdg.forEach(v => { t++; if (sdgs.has(v)) m++; });
return { matched: m, total: t };
}
function isCompleteMatch(playerCards: Card[], challenge: Card) {
const r = matchCount(playerCards, challenge);
return r.matched === r.total && r.total > 0;
}
function aiChooseCard(p: Player, challenge: Card): number {
let bestIdx = 0, bestScore = -1;
for (let i = 0; i < p.hand.length; i++) {
const test = [...p.projectZone, p.hand[i]];
const r = matchCount(test, challenge);
if (r.matched > bestScore || (r.matched === bestScore && Math.random() > 0.5)) {
bestScore = r.matched;
bestIdx = i;
}
}
return bestIdx;
}
function calculateScores(players: Player[], challenge: Card): { players: Player[]; collaborativeWin: boolean } {
const p2 = JSON.parse(JSON.stringify(players)) as Player[];
let anyFullMatch = false;
for (const p of p2) {
const info = matchCount(p.projectZone, challenge);
p.matchInfo = info;
if (info.matched === info.total && info.total > 0) anyFullMatch = true;
}
if (anyFullMatch) {
let bestIdx = 0, bestMatch = 0;
p2.forEach((p, i) => {
if (p.matchInfo!.matched > bestMatch) { bestMatch = p.matchInfo!.matched; bestIdx = i; }
});
p2[bestIdx].points += 2;
p2.forEach((p, i) => {
if (i !== bestIdx && p.matchInfo!.matched === p.matchInfo!.total) p.points += 1;
});
return { players: p2, collaborativeWin: false };
}
const combined = { ae: new Set<number>(), mfl: new Set<number>(), sdgs: new Set<number>() };
p2.forEach(p => p.projectZone.forEach(c => {
c.ae.forEach(v => combined.ae.add(v));
c.mfl.forEach(v => combined.mfl.add(v));
c.sdgs.forEach(v => combined.sdgs.add(v));
}));
const cae = new Set(challenge.ae), cmfl = new Set(challenge.mfl), csdg = new Set(challenge.sdgs);
let allCovered = true;
cae.forEach(v => { if (!combined.ae.has(v)) allCovered = false; });
cmfl.forEach(v => { if (!combined.mfl.has(v)) allCovered = false; });
csdg.forEach(v => { if (!combined.sdgs.has(v)) allCovered = false; });
if (allCovered) {
p2.forEach(p => p.points += 1);
return { players: p2, collaborativeWin: true };
}
return { players: p2, collaborativeWin: false };
}
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
export interface GameAPI {
phase: GamePhase;
deck: Card[];
challenge: Card | null;
players: Player[];
currentPlayer: number;
round: number;
selectedCard: number | null;
statusMessage: string;
statusClass: string;
showResults: boolean;
animatingCardId: number | null;
turnBanner: string;
challengeReveal: string | null;
collaborativeWin: boolean;
matchMode: MatchMode;
matchIndex: number;
matchHistory: MatchRecord[];
seriesWinner: number | null;
startGame: (aiCount: number, mode: MatchMode) => void;
nextMatch: () => void;
selectCard: (index: number | null) => void;
playCard: () => void;
endTurn: () => void;
handleReplace: (doReplace: boolean) => void;
backToMenu: () => void;
suddenSolvePlayCard: (index: number) => void;
suddenSolveUseBackup: (slotIndex?: number) => void;
suddenSolveSkipBackup: () => void;
applyBackupChoice: (slotIndex?: number) => void;
}
export function useGameState(): GameAPI {
const [phase, setPhase] = useState<GamePhase>('menu');
const [deck, setDeck] = useState<Card[]>([]);
const [challenge, setChallenge] = useState<Card | null>(null);
const [players, setPlayers] = useState<Player[]>([]);
const [currentPlayer, setCurrentPlayer] = useState(0);
const [round, setRound] = useState(0);
const [selectedCard, setSelectedCard] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [statusClass, setStatusClass] = useState('');
const [showResults, setShowResults] = useState(false);
const [animatingCardId, setAnimatingCardId] = useState<number | null>(null);
const [turnBanner, setTurnBanner] = useState('');
const [challengeReveal, setChallengeReveal] = useState<string | null>(null);
const [collaborativeWin, setCollaborativeWin] = useState(false);
const [matchMode, setMatchMode] = useState<MatchMode>('single');
const [matchIndex, setMatchIndex] = useState(0);
const [matchHistory, setMatchHistory] = useState<MatchRecord[]>([]);
const cancelRef = useRef(false);
const setStatus = useCallback((msg: string, cls = '') => {
setStatusMessage(msg);
setStatusClass(cls);
}, []);
const dealMatch = useCallback(async () => {
const d = shuffle([...CARDS]);
const ch = d.pop()!;
const p: Player[] = [
{ name: 'You', isHuman: true, hand: [], projectZone: [], backupCard: null, usedReplace: false, points: 0, hasActed: false, matchInfo: null }
];
const aiCount = 1;
for (let i = 1; i <= aiCount; i++) {
p.push({ name: `AI ${i}`, isHuman: false, hand: [], projectZone: [], backupCard: null, usedReplace: false, points: 0, hasActed: false, matchInfo: null });
}
p.forEach(pl => {
pl.hand = d.splice(0, 5);
pl.backupCard = d.pop() || null;
});
setDeck(d);
setChallenge(ch);
setPlayers(p);
setCurrentPlayer(0);
setRound(1);
setSelectedCard(null);
setCollaborativeWin(false);
setShowResults(false);
setAnimatingCardId(null);
setTurnBanner('');
setStatus('', '');
setPhase('setup');
setChallengeReveal('intro');
await delay(1500);
setChallengeReveal('3');
await delay(1000);
setChallengeReveal('2');
await delay(1000);
setChallengeReveal('1');
await delay(1000);
setChallengeReveal(null);
setPhase('replace');
setStatus('Global challenge revealed! Review the icons, then decide if you want to Replace! your hand.', 'highlight');
}, []);
const startGame = useCallback(async (aiCount: number, mode: MatchMode) => {
cancelRef.current = false;
setMatchMode(mode);
setMatchIndex(0);
setMatchHistory([]);
await dealMatch();
}, [dealMatch]);
const nextMatch = useCallback(async () => {
cancelRef.current = false;
setMatchIndex(i => i + 1);
await dealMatch();
}, [dealMatch]);
const handleReplace = useCallback(async (doReplace: boolean) => {
const p = JSON.parse(JSON.stringify(players)) as Player[];
let d = [...deck];
if (doReplace) {
d.push(...p[0].hand);
const shuffled = shuffle(d);
p[0].hand = shuffled.splice(0, 5);
p[0].usedReplace = true;
d = shuffled;
}
for (let i = 1; i < p.length; i++) {
const iconCount = p[i].hand.reduce((s, c) => s + c.ae.length + c.mfl.length + c.sdgs.length, 0);
if (iconCount < 5) {
d.push(...p[i].hand);
const shuffled = shuffle(d);
p[i].hand = shuffled.splice(0, 5);
p[i].usedReplace = true;
d = shuffled;
}
}
setDeck(d);
setPlayers(p);
setPhase('play_turn');
setCurrentPlayer(0);
setSelectedCard(null);
setStatus('Year 1 — Project 1 — Select a card to play.', '');
}, [players, deck]);
const selectCard = useCallback((index: number | null) => {
if (phase !== 'play_turn' && phase !== 'sudden_solve') return;
setSelectedCard(index);
}, [phase]);
const playCard = useCallback(() => {
if (selectedCard === null || phase !== 'play_turn') return;
const p = JSON.parse(JSON.stringify(players)) as Player[];
const human = p[0];
if (selectedCard >= human.hand.length || human.hasActed) return;
const card = human.hand.splice(selectedCard, 1)[0];
human.projectZone.push(card);
human.hasActed = true;
setSelectedCard(null);
setPlayers(p);
setAnimatingCardId(card.id);
setTimeout(async () => {
setAnimatingCardId(null);
if (cancelRef.current) return;
if (isCompleteMatch(human.projectZone, challenge!)) {
setStatus('SUDDEN SOLVE! You matched all challenge icons!', 'sudden');
await delay(1200);
await runSuddenSolve(0, JSON.parse(JSON.stringify(p)), challenge!);
return;
}
if (round < 3) {
setPhase('end_turn_prompt');
setStatus('Card played! Click End Turn.', '');
} else {
setPhase('end_turn_prompt');
setStatus('Card played.', '');
}
}, 500);
}, [selectedCard, phase, players, round, challenge]);
const runSuddenSolve = async (triggerIdx: number, p: Player[], ch: Card) => {
setPhase('sudden_solve');
p.forEach(pl => pl.hasActed = false);
setPlayers([...p]);
for (let i = 0; i < p.length; i++) {
if (i === triggerIdx) continue;
const pl = p[i];
if (cancelRef.current) return;
if (pl.isHuman) {
setStatus('Sudden Solve! Play a card from your hand if you wish.', 'highlight');
setSelectedCard(null);
return;
} else {
if (pl.hand.length > 0) {
const ci = aiChooseCard(pl, ch);
const played = pl.hand.splice(ci, 1)[0];
pl.projectZone.push(played);
setAnimatingCardId(played.id);
setPlayers([...p]);
await delay(600);
setAnimatingCardId(null);
}
if (pl.backupCard && !cancelRef.current) {
await aiUseBackup(pl, ch);
setPlayers([...p]);
await delay(400);
}
}
}
await finishGame(p, ch);
};
const aiUseBackup = async (pl: Player, ch: Card) => {
const testCards = [...pl.projectZone, pl.backupCard!];
if (isCompleteMatch(testCards, ch)) {
pl.projectZone.push(pl.backupCard!);
pl.backupCard = null;
} else {
let bestGain = 0, bestSlot = -1;
for (let s = 0; s < pl.projectZone.length; s++) {
const alt = [...pl.projectZone];
alt[s] = pl.backupCard!;
const curR = matchCount(pl.projectZone, ch);
const newR = matchCount(alt, ch);
const gain = newR.matched - curR.matched;
if (gain > bestGain) { bestGain = gain; bestSlot = s; }
}
if (bestSlot >= 0 && bestGain > 0) {
pl.hand.push(pl.projectZone[bestSlot]);
pl.projectZone[bestSlot] = pl.backupCard!;
pl.backupCard = null;
}
}
};
const suddenSolvePlayCard = useCallback((index: number) => {
if (phase !== 'sudden_solve') return;
const p = JSON.parse(JSON.stringify(players)) as Player[];
if (index >= 0 && index < p[0].hand.length) {
const card = p[0].hand.splice(index, 1)[0];
p[0].projectZone.push(card);
setPlayers(p);
setAnimatingCardId(card.id);
setTimeout(async () => {
setAnimatingCardId(null);
if (cancelRef.current) return;
if (p[0].backupCard) {
setStatus('Do you want to use your Backup Project Card?', 'highlight');
return;
}
await continueAfterHuman(p);
}, 500);
}
}, [phase, players]);
const suddenSolveUseBackup = useCallback((slotIndex?: number) => {
if (phase !== 'sudden_solve') return;
const p = JSON.parse(JSON.stringify(players)) as Player[];
if (p[0].backupCard) {
if (slotIndex !== undefined && slotIndex >= 0 && slotIndex < p[0].projectZone.length) {
p[0].hand.push(p[0].projectZone[slotIndex]);
p[0].projectZone[slotIndex] = p[0].backupCard;
p[0].backupCard = null;
} else if (slotIndex === -1) {
p[0].projectZone.push(p[0].backupCard);
p[0].backupCard = null;
}
setPlayers(p);
}
continueAfterHuman(p);
}, [phase, players]);
const suddenSolveSkipBackup = useCallback(() => {
if (phase !== 'sudden_solve') return;
const p = JSON.parse(JSON.stringify(players)) as Player[];
continueAfterHuman(p);
}, [phase, players]);
const continueAfterHuman = async (p: Player[]) => {
if (cancelRef.current) return;
const ch = challenge!;
for (let i = 1; i < p.length; i++) {
const pl = p[i];
if (cancelRef.current) return;
if (pl.hand.length === 0 && !pl.backupCard) continue;
if (pl.hand.length > 0) {
const ci = aiChooseCard(pl, ch);
const played = pl.hand.splice(ci, 1)[0];
pl.projectZone.push(played);
setAnimatingCardId(played.id);
setPlayers([...p]);
await delay(600);
setAnimatingCardId(null);
}
if (pl.backupCard && !cancelRef.current) {
await aiUseBackup(pl, ch);
setPlayers([...p]);
await delay(400);
}
}
await finishGame(p, ch);
};
const completeScoring = async (p: Player[], ch: Card) => {
p[0].matchInfo = matchCount(p[0].projectZone, ch);
setPlayers(p);
const { players: scored, collaborativeWin } = calculateScores(p, ch);
setPlayers(scored);
setCollaborativeWin(collaborativeWin);
await delay(600);
const matchScores = scored.map(pl => pl.points);
const maxScore = Math.max(...matchScores);
const mWinner = maxScore > 0 ? matchScores.indexOf(maxScore) : null;
setMatchHistory(h => [...h, { scores: matchScores, winnerIndex: mWinner, collaborativeWin }]);
setPhase('done');
setShowResults(true);
setStatus('', '');
setTurnBanner('');
};
const applyBackupChoice = useCallback((slotIndex?: number) => {
if (phase !== 'backup_choice') return;
const p = JSON.parse(JSON.stringify(players)) as Player[];
const ch = challenge!;
if (slotIndex !== undefined && slotIndex >= 0 && slotIndex < p[0].projectZone.length) {
p[0].hand.push(p[0].projectZone[slotIndex]);
p[0].projectZone[slotIndex] = p[0].backupCard!;
p[0].backupCard = null;
}
completeScoring(p, ch);
}, [phase, players, challenge]);
const finishGame = async (p: Player[], ch: Card) => {
setPhase('scoring');
setStatus('Game Over! Calculating results...', '');
// AI backup optimization
for (let i = 1; i < p.length; i++) {
const pl = p[i];
if (pl.backupCard) {
const curR = matchCount(pl.projectZone, ch);
let bestR = curR.matched, bestSlot = -1;
for (let s = 0; s < pl.projectZone.length; s++) {
const alt = [...pl.projectZone];
alt[s] = pl.backupCard!;
const r = matchCount(alt, ch);
if (r.matched > bestR) { bestR = r.matched; bestSlot = s; }
}
if (bestSlot >= 0 && bestR > curR.matched) {
pl.projectZone[bestSlot] = pl.backupCard!;
pl.backupCard = null;
}
}
pl.matchInfo = matchCount(pl.projectZone, ch);
}
// Human backup choice — pause and ask
if (p[0].backupCard) {
setPlayers(p);
setPhase('backup_choice');
setStatus('Do you want to replace a project card with your Backup Project Card?', 'highlight');
return;
}
await completeScoring(p, ch);
};
const endTurn = useCallback(() => {
if (phase !== 'end_turn_prompt') return;
const p = JSON.parse(JSON.stringify(players)) as Player[];
const d = [...deck];
let cp = currentPlayer + 1;
let r = round;
const advanceRound = async () => {
r++;
setRound(r);
setTurnBanner(`Year ${r}`);
setStatus(`Year ${r} begins!`, 'highlight');
await delay(1200);
setTurnBanner('');
for (let i = 0; i < p.length; i++) {
if (p[i].hand.length < 5 && d.length > 0) {
p[i].hand.push(d.pop()!);
}
p[i].hasActed = false;
}
setDeck([...d]);
setPlayers([...p]);
setPhase('play_turn');
setCurrentPlayer(0);
setSelectedCard(null);
setStatus(`Year ${r} — Project ${r} — Select a card to play.`, '');
};
const processAITurns = async () => {
while (cp < p.length) {
const ai = p[cp];
if (cancelRef.current) return;
setCurrentPlayer(cp);
setStatus(`${ai.name} is thinking...`, '');
await delay(800 + Math.random() * 600);
if (cancelRef.current) return;
const ci = aiChooseCard(ai, challenge!);
const played = ai.hand.splice(ci, 1)[0];
ai.projectZone.push(played);
ai.hasActed = true;
setPlayers([...p]);
setAnimatingCardId(played.id);
await delay(500);
setAnimatingCardId(null);
if (isCompleteMatch(ai.projectZone, challenge!)) {
setStatus(`${ai.name} completed the challenge! Sudden Solve!`, 'sudden');
await delay(1200);
await runSuddenSolve(cp, JSON.parse(JSON.stringify(p)), challenge!);
return;
}
cp++;
}
if (r < 3) {
await advanceRound();
} else {
await finishGame(p, challenge!);
}
};
processAITurns();
}, [phase, currentPlayer, round, players, deck, challenge]);
let seriesWinner: number | null = null;
if (matchMode !== 'single' && matchHistory.length > 0) {
const wins = [0, 0];
const needed = matchMode === 'bo2' ? 2 : 2;
for (const m of matchHistory) {
if (m.winnerIndex !== null) wins[m.winnerIndex]++;
}
const totalMatches = matchHistory.length;
if (matchMode === 'bo2' && totalMatches >= 2) {
if (wins[0] > wins[1]) seriesWinner = 0;
else if (wins[1] > wins[0]) seriesWinner = 1;
} else if (matchMode === 'bo3' && (wins[0] >= 2 || wins[1] >= 2 || totalMatches >= 3)) {
if (wins[0] > wins[1]) seriesWinner = 0;
else if (wins[1] > wins[0]) seriesWinner = 1;
}
}
const backToMenu = useCallback(() => {
cancelRef.current = true;
setPhase('menu');
setShowResults(false);
setChallenge(null);
setPlayers([]);
setStatusMessage('');
setStatusClass('');
setSelectedCard(null);
setTurnBanner('');
setAnimatingCardId(null);
setCollaborativeWin(false);
setMatchMode('single');
setMatchIndex(0);
setMatchHistory([]);
}, []);
return {
phase, deck, challenge, players, currentPlayer, round,
selectedCard, statusMessage, statusClass, showResults,
animatingCardId, turnBanner, challengeReveal, collaborativeWin,
matchMode, matchIndex, matchHistory, seriesWinner,
startGame, nextMatch, selectCard, playCard, endTurn,
handleReplace, backToMenu,
suddenSolvePlayCard, suddenSolveUseBackup, suddenSolveSkipBackup, applyBackupChoice,
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1441
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
export interface Card {
id: number;
name: string;
action_text: string;
description: string;
ae: number[];
mfl: number[];
sdgs: number[];
}
export interface Player {
name: string;
isHuman: boolean;
hand: Card[];
projectZone: Card[];
backupCard: Card | null;
usedReplace: boolean;
points: number;
hasActed: boolean;
matchInfo: MatchInfo | null;
}
export interface MatchInfo {
matched: number;
total: number;
}
export interface MatchRecord {
scores: number[];
winnerIndex: number | null;
collaborativeWin: boolean;
}
export type MatchMode = 'single' | 'bo2' | 'bo3';
export type GamePhase =
| 'menu'
| 'setup'
| 'replace'
| 'play_turn'
| 'end_turn_prompt'
| 'turn_transition'
| 'sudden_solve'
| 'scoring'
| 'backup_choice'
| 'done';
export interface ModalConfig {
title: string;
body: string;
buttons: { text: string; value: string; accent?: boolean }[];
}
export interface GameState {
phase: GamePhase;
deck: Card[];
challenge: Card | null;
players: Player[];
currentPlayer: number;
round: number;
selectedCard: number | null;
suddenSolveActive: boolean;
gameOver: boolean;
modal: ModalConfig | null;
modalResolve: ((value: string) => void) | null;
statusMessage: string;
statusClass: string;
animatingCard: { card: Card; from: 'hand' | 'deck'; to: 'project' | 'challenge' } | null;
}
export type GameAction =
| { type: 'START_GAME'; aiCount: number }
| { type: 'REPLACE_HAND' }
| { type: 'KEEP_HAND' }
| { type: 'SELECT_CARD'; index: number | null }
| { type: 'PLAY_CARD' }
| { type: 'END_TURN' }
| { type: 'AI_PLAY_CARD'; playerIndex: number; cardIndex: number }
| { type: 'NEXT_PHASE' }
| { type: 'SET_STATUS'; message: string; cls?: string }
| { type: 'SUDDEN_SOLVE_PLAY'; cardIndex: number }
| { type: 'SUDDEN_SOLVE_SKIP' }
| { type: 'USE_BACKUP'; slotIndex?: number }
| { type: 'SKIP_BACKUP' }
| { type: 'DISMISS_MODAL' };
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />