feat: make flat-stack-presejpacky standalone with Docker and Makefile

This commit is contained in:
2026-06-07 16:48:15 +02:00
parent 164230356c
commit b7e4bbb49d
21 changed files with 5110 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
node_modules
dist
server.js
.env
.env.local
.git
.gitignore
Makefile
Dockerfile
README.md

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,37 @@
# Stage 1: Build stage
FROM node:24-alpine AS builder
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci
# Copy the rest of the application files
COPY . .
# Build frontend and server.ts
RUN npm run build
# Stage 2: Production stage
FROM node:24-alpine AS runner
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
ENV PORT=3000
# Copy package files and install only production dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy built assets and compiled server from builder stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server.js ./server.js
# Expose port
EXPOSE 3000
# Run the server
CMD ["node", "server.js"]

View File

@@ -0,0 +1,46 @@
# Variables
IMAGE_NAME = flat-stack-presejpacky
PORT = 3000
.PHONY: help install build dev start clean lint docker-build docker-run docker-stop
help:
@echo "Available commands:"
@echo " make install - Install project dependencies"
@echo " make dev - Run the application locally in development mode (Vite)"
@echo " make build - Build the production assets"
@echo " make start - Run the production build locally using Express"
@echo " make clean - Remove build artifacts (dist/ and server.js)"
@echo " make lint - Run TypeScript type checks"
@echo " make docker-build - Build the Docker image"
@echo " make docker-run - Run the Docker image locally on port $(PORT)"
@echo " make docker-stop - Stop and remove the Docker container"
install:
docker run -it --rm -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine npm install
dev:
docker run -it --rm -p $(PORT):$(PORT) -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run dev"
build:
docker run -it --rm -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run build"
start:
docker run -it --rm -p $(PORT):$(PORT) -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run start"
clean:
docker run -it --rm -v $$(pwd):/app -w /app node:24-alpine npm run clean
lint:
docker run -it --rm -v $$(pwd):/app -v /app/node_modules -w /app node:24-alpine sh -c "npm install && npm run lint"
docker-build:
docker build -t $(IMAGE_NAME) .
docker-run:
docker run -d -p $(PORT):$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME)
@echo "Container is running at http://localhost:$(PORT)"
docker-stop:
docker stop $(IMAGE_NAME) || true
docker rm $(IMAGE_NAME) || true

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/6348f874-622e-4303-a154-d6e93300a7b9
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "",
"description": "",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build && esbuild server.ts --platform=node --format=esm --outfile=server.js",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit",
"start": "node server.js"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"tailwind-merge": "^3.6.0",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

View File

@@ -0,0 +1,21 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
// Serve static files from the 'dist' directory
app.use(express.static(path.join(__dirname, 'dist')));
// Fallback to index.html for Single Page Application routing
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

View File

@@ -0,0 +1,225 @@
import { useState, useEffect } from 'react';
import { FieldCanvas } from './components/FieldCanvas';
import { DrillTimeline } from './components/DrillTimeline';
import { STEPS, getPositionsForStep } from './lib/drill';
import { Play, Pause, RotateCcw, LayoutTemplate, Info, X, SkipBack, SkipForward } from 'lucide-react';
import './index.css';
export default function App() {
const [cycle, setCycle] = useState(0);
const [step, setStep] = useState(0);
const [playing, setPlaying] = useState(false);
const [showInfo, setShowInfo] = useState(false);
useEffect(() => {
if (!playing) return;
const currentDuration = STEPS[step].duration;
// Automatically transition to the next step
const timer = setTimeout(() => {
if (step === STEPS.length - 1) {
setStep(0);
setCycle(c => c + 1);
} else {
setStep(s => s + 1);
}
}, currentDuration);
return () => clearTimeout(timer);
}, [playing, step, cycle]);
// Derive all rendered positions mathematically from cycle and step
const { pos, discX, discY, markX, markY, markAngle } = getPositionsForStep(cycle, step);
return (
<div className="flex flex-col h-screen w-full bg-slate-900 text-slate-100 font-sans overflow-hidden">
{/* Header Navigation */}
<header className="flex items-center justify-between px-6 lg:px-8 py-4 bg-slate-800/50 border-b border-slate-700 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center shadow-lg shadow-indigo-500/20">
<LayoutTemplate className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight text-white flex items-center gap-2">
Half-Flat Break-Side Pivot
<button
onClick={() => setShowInfo(true)}
className="ml-2 p-1 text-slate-400 hover:text-indigo-400 hover:bg-slate-800 rounded-full transition-colors"
title="Drill Description"
>
<Info className="w-5 h-5" />
</button>
</h1>
<p className="text-[10px] sm:text-xs text-slate-400 uppercase tracking-widest leading-none mt-1">Tactical Drill Simulator</p>
</div>
</div>
</header>
{/* Main Workspace */}
<main className="flex flex-1 overflow-hidden relative">
{/* Sidebar Controls */}
<aside className="w-80 border-r border-slate-700 bg-slate-800/30 p-6 flex-col gap-6 overflow-y-auto shrink-0 hidden lg:flex custom-scrollbar">
<section className="bg-slate-800/50 border border-slate-700/50 p-4 rounded-xl shadow-inner">
<h3 className="text-[10px] font-bold text-indigo-400 uppercase mb-2 tracking-widest">Current Action Focus</h3>
<h4 className="text-base font-semibold text-white mb-2">{STEPS[step].name}</h4>
<p className="text-sm text-slate-300 leading-relaxed">
{STEPS[step].description}
</p>
</section>
<section className="flex-1 flex flex-col min-h-0">
<h3 className="text-xs font-bold text-slate-500 uppercase mb-4 flex justify-between items-center">
Active Rotation
<span className="px-2 py-0.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 font-mono text-[10px] rounded">
CYCLE {cycle + 1}
</span>
</h3>
<DrillTimeline currentStep={step} cycle={cycle} />
</section>
</aside>
{/* Tactical Field Visualizer */}
<div className="flex-1 relative bg-slate-950 p-4 md:p-12 flex flex-col items-center justify-center overflow-hidden">
<div className="w-full flex-1 max-h-[800px] flex items-center justify-center p-4">
<div className="w-full max-w-[500px] aspect-[5/7] h-full max-h-full">
<FieldCanvas
pos={pos}
discX={discX}
discY={discY}
markX={markX}
markY={markY}
markAngle={markAngle}
stepDuration={STEPS[step].duration}
step={step}
/>
</div>
</div>
{/* Overlay Legend */}
<div className="absolute bottom-6 md:bottom-12 right-6 md:right-12 bg-slate-800/80 backdrop-blur-md border border-slate-700 p-3 sm:p-4 rounded-xl flex gap-4 sm:gap-6 shadow-xl z-20">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.5)]"></div>
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Handler</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-slate-400 rounded-full border border-slate-300"></div>
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Cutter</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-600 rounded-sm rotate-45"></div>
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Defense</span>
</div>
</div>
</div>
{/* Info Modal Overlay */}
{showInfo && (
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl max-w-2xl w-full flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-6 border-b border-slate-800 bg-slate-800/30">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Info className="w-6 h-6 text-indigo-400" /> Drill Overview
</h2>
<button
onClick={() => setShowInfo(false)}
className="p-2 bg-slate-800 hover:bg-slate-700 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[70vh] flex flex-col gap-4 text-slate-300">
<p className="text-base leading-relaxed">
The <strong className="text-white">Isolation Break Drill</strong> focuses on exploiting the defense using handler movement to open up the break side from a half-flatstack setup.
</p>
<div className="bg-slate-800/50 p-4 rounded-xl border border-slate-700">
<h3 className="font-semibold text-indigo-300 mb-3 uppercase tracking-wider text-xs">Sequence of Motions</h3>
<ol className="list-decimal list-outside ml-4 space-y-3 text-sm">
<li><strong className="text-slate-100">Initiation:</strong> The breakside handler drives hard to the open side, threatening a give-and-go cut. The mark tracks this movement.</li>
<li><strong className="text-slate-100">The Fake:</strong> The central handler (with the disc) fakes a throw to the open side, forcing the mark to bite and shift their weight fully into the open lane.</li>
<li><strong className="text-slate-100">The Pivot:</strong> Exploiting the mark's overcommitment, the central handler rapidly pivots back to the breakside, stepping around the off-balance defender.</li>
<li><strong className="text-slate-100">The Cut:</strong> Synchronized perfectly with the thrower's pivot, the cutter on the line (breakside) makes a hard cut parallel to the sideline towards the disc.</li>
<li><strong className="text-slate-100">The Throw:</strong> The handler delivers a breakside pass into space, allowing the cutter to catch it in stride and continue advancing upfield.</li>
</ol>
</div>
<div className="bg-emerald-900/20 border border-emerald-500/30 p-4 rounded-xl">
<p className="text-sm text-emerald-200 leading-relaxed">
<span className="font-bold text-emerald-400">Coaching Tip:</span> Ensure the line cutter doesn't start their cut too early. They must wait for the central handler's eyes to snap back to the break side before initiating the horizontal cut so they arrive exactly on time.
</p>
</div>
</div>
</div>
</div>
)}
</main>
{/* Playback Control Bar */}
<footer className="h-20 lg:h-24 bg-slate-900 border-t border-slate-700 flex items-center px-6 lg:px-12 gap-6 lg:gap-8 shrink-0 z-10">
<div className="flex items-center gap-3 md:gap-4">
<button
onClick={() => {
setPlaying(false);
if (step > 0) setStep(step - 1);
else if (cycle > 0) { setCycle(cycle - 1); setStep(STEPS.length - 1); }
}}
className="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50"
title="Previous Step"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={() => setPlaying(!playing)}
className="w-14 h-14 flex items-center justify-center bg-indigo-600 rounded-full shadow-lg shadow-indigo-500/30 hover:scale-105 active:scale-95 transition-all text-white border border-indigo-400/50"
title={playing ? "Pause" : "Play"}
>
{playing ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />}
</button>
<button
onClick={() => {
setPlaying(false);
if (step < STEPS.length - 1) setStep(step + 1);
else { setCycle(cycle + 1); setStep(0); }
}}
className="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50"
title="Next Step"
>
<SkipForward className="w-4 h-4" />
</button>
<button
onClick={() => { setStep(0); setCycle(0); setPlaying(false); }}
className="w-10 h-10 hidden sm:flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50 ml-2"
title="Restart Drill"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="flex-1 flex flex-col gap-2 mx-2">
<div className="flex justify-between text-[10px] font-mono text-slate-500 uppercase tracking-widest px-1">
<span>{String((step * 0.45).toFixed(2)).padStart(5, '0')}</span>
<span className="font-semibold text-indigo-400 text-xs hidden sm:inline">{STEPS[step]?.name}</span>
<span>{String(((STEPS.length - 1) * 0.45).toFixed(2)).padStart(5, '0')}</span>
</div>
<div className="relative h-2 w-full bg-slate-800 rounded-full overflow-hidden border border-slate-700/50">
<div
className="absolute top-0 left-0 h-full bg-indigo-500 transition-all duration-300 ease-linear shadow-[0_0_10px_rgba(99,102,241,0.8)]"
style={{ width: `${Math.max(2, ((step) / Math.max(1, STEPS.length - 1)) * 100)}%` }}
></div>
<div className="absolute top-0 w-0.5 h-full bg-white shadow-[0_0_5px_white] transition-all duration-300 ease-linear" style={{ left: `calc(${Math.max(2, ((step) / Math.max(1, STEPS.length - 1)) * 100)}% - 2px)` }}></div>
</div>
</div>
<div className="hidden md:flex items-center gap-6 text-slate-400">
<div className="flex flex-col items-end">
<span className="text-[10px] font-bold uppercase tracking-tighter text-slate-500">Speed</span>
<span className="text-xs font-mono text-indigo-400 font-semibold">1.0x</span>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { STEPS } from '../lib/drill';
export function DrillTimeline({ currentStep }: { currentStep: number, cycle: number }) {
return (
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1 pr-1 pb-4">
{STEPS.map((s, i) => {
const isActive = i === currentStep;
const isPassed = i < currentStep;
if (isActive) {
return (
<div key={i} className="flex flex-col p-3.5 bg-indigo-500/10 border border-indigo-500/30 rounded-lg shadow-sm gap-2 transition-all">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-indigo-300">{s.name}</span>
<span className="text-xs bg-indigo-500 text-white px-2.5 py-0.5 rounded shadow-sm shadow-indigo-500/50 font-medium tracking-wide">Active</span>
</div>
</div>
);
} else if (isPassed) {
return (
<div key={i} className="flex flex-col p-3.5 bg-slate-700/20 border border-slate-700 rounded-lg opacity-60 gap-1.5 transition-all">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300">{s.name}</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Done</span>
</div>
</div>
);
} else {
return (
<div key={i} className="flex flex-col p-3.5 bg-slate-800/30 border border-slate-700/50 rounded-lg opacity-40 gap-1.5 transition-all">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">{s.name}</span>
<span className="text-[10px] text-slate-600 uppercase tracking-widest font-semibold">Next</span>
</div>
</div>
);
}
})}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { motion } from 'motion/react';
import { PlayerId } from '../types';
import { PLAYERS_UI, P_B, P_D } from '../lib/drill';
interface FieldCanvasProps {
pos: Record<PlayerId, { x: number, y: number }>;
discX: number;
discY: number;
markX: number;
markY: number;
markAngle?: number;
stepDuration: number;
step: number;
}
export function FieldCanvas({ pos, discX, discY, markX, markY, markAngle = 0, stepDuration, step }: FieldCanvasProps) {
const transition = { duration: stepDuration / 1000, ease: 'easeInOut' };
return (
<div className="relative w-full h-full bg-emerald-900/50 rounded border-2 border-emerald-500/30 overflow-hidden shadow-[inset_0_0_40px_rgba(0,0,0,0.5)]">
{/* Field Markings HTML layer */}
<div className="absolute inset-0 grid grid-cols-4 divide-x divide-emerald-500/20 pointer-events-none">
<div></div><div></div><div></div><div></div>
</div>
<div className="absolute inset-0 flex flex-col justify-between py-[12%] px-0 pointer-events-none">
<div className="w-full h-[1px] bg-white/20 shadow-[0_0_2px_rgba(255,255,255,0.4)]"></div>
<div className="w-full h-[1px] bg-white/20 shadow-[0_0_2px_rgba(255,255,255,0.4)]"></div>
</div>
{/* SVG Animation layer */}
<svg
viewBox="0 0 500 700"
className="absolute inset-0 w-full h-full"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<filter id="dropshadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" floodOpacity="0.4" floodColor="#020617" />
</filter>
</defs>
{/* Action Trajectory Paths */}
<g>
<path
d={`M ${P_B.x} ${P_B.y} Q 250 350 350 300`}
stroke="#818cf8"
strokeWidth="3"
strokeDasharray="6 6"
fill="none"
className="transition-opacity duration-300"
opacity={step >= 1 && step <= 6 ? 0.8 : 0}
/>
{step >= 1 && step <= 6 && (
<circle cx="350" cy="300" r="4" fill="#818cf8" />
)}
<path
d={`M ${P_D.x} ${P_D.y} L 100 250`}
stroke="#fbbf24"
strokeWidth="3"
strokeDasharray="6 6"
fill="none"
className="transition-opacity duration-300"
opacity={step >= 3 && step <= 6 ? 0.8 : 0}
/>
{step >= 3 && step <= 6 && (
<circle cx="100" cy="250" r="4" fill="#fbbf24" />
)}
</g>
{/* Players */}
{(Object.entries(pos) as [string, {x:number, y:number}][]).map(([idStr, coords]) => {
const id = parseInt(idStr) as PlayerId;
const ui = PLAYERS_UI[id];
// Determine styling based on position (handlers typically at bottom half)
const isHandler = coords.y > 400;
const fill = isHandler ? '#3b82f6' : '#94a3b8'; // Handler: blue, Cutter: slate
const labelPrefix = isHandler ? 'H' : 'C';
return (
<motion.g key={id} animate={{ x: coords.x, y: coords.y }} transition={transition} filter="url(#dropshadow)">
<circle cx="0" cy="0" r="16" fill={fill} stroke="#ffffff" strokeWidth="2.5" />
<text x="0" y="4" textAnchor="middle" fill="#ffffff" fontSize="11" fontWeight="bold" fontFamily="sans-serif">
{labelPrefix}{ui.label}
</text>
</motion.g>
);
})}
{/* Mark (Defender) */}
<motion.g animate={{ x: markX, y: markY }} transition={transition} filter="url(#dropshadow)">
<motion.g animate={{ rotate: markAngle }} transition={transition}>
{/* Arms span */}
<rect x="-22" y="-2" width="44" height="4" rx="2" fill="#ef4444" stroke="#7f1d1d" strokeWidth="0.5" />
{/* Hands */}
<circle cx="-22" cy="0" r="3.5" fill="#fca5a5" />
<circle cx="22" cy="0" r="3.5" fill="#fca5a5" />
{/* Head */}
<circle cx="0" cy="0" r="7" fill="#b91c1c" stroke="#ffffff" strokeWidth="1.5" />
</motion.g>
<text x="0" y="16" textAnchor="middle" fill="#ef4444" fontSize="10" fontWeight="bold" fontFamily="sans-serif">M</text>
</motion.g>
{/* Disc */}
<motion.g animate={{ x: discX, y: discY }} transition={transition}>
<g filter="url(#dropshadow)">
<circle r="9" fill="#ffffff" stroke="#94a3b8" strokeWidth="1" />
<circle r="6" fill="none" stroke="#e2e8f0" strokeWidth="1" />
<line x1="-4" y1="0" x2="4" y2="0" stroke="#cbd5e1" strokeWidth="1.5" />
</g>
</motion.g>
</svg>
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,130 @@
import { PlayerId, StepAction, PlayerRoles, Position } from '../types';
export const STEPS: StepAction[] = [
{ name: 'Setup', duration: 800, description: 'H2 (Central) holds disc. H1 (Breakside) prepares. C1 & C2 hold flatstack positions.' },
{ name: 'Initiate Cut', duration: 1000, description: 'H1 drives into the open side for a potential give-and-go. M (Mark) tracks the movement.' },
{ name: 'Fake Open Side', duration: 600, description: 'H2 fakes the pass to H1. M bites and shifts weight to block the open side throw.' },
{ name: 'Pivot & Break Cut', duration: 900, description: 'H2 quickly pivots to the breakside. C2 (Line Cutter) sees the pivot and cuts parallel to the sideline towards the disc.' },
{ name: 'Throw Break', duration: 500, description: 'H2 throws the break pass. C2 continues stride along the sideline to meet the disc.' },
{ name: 'Caught', duration: 600, description: 'C2 catches the disc in stride, gaining forward momentum.' },
{ name: 'Rotate', duration: 1400, description: 'H2 clears to breakside. H1 to C1. C1 to C2. C2 clears to queue. Queue enters as H2.' },
];
export const getCycleRoles = (cycle: number): PlayerRoles => {
const arr: PlayerId[] = [1, 2, 3, 4, 5];
const shifts = cycle % 5;
for (let i = 0; i < shifts; i++) {
const last = arr.pop();
if (last) arr.unshift(last);
}
return { A: arr[0], B: arr[1], C: arr[2], D: arr[3], E: arr[4] };
};
export const PLAYERS_UI: Record<PlayerId, { color: string; label: string }> = {
1: { color: '#3b82f6', label: '1' },
2: { color: '#eab308', label: '2' },
3: { color: '#a855f7', label: '3' },
4: { color: '#ec4899', label: '4' },
5: { color: '#f97316', label: '5' },
};
// Nominal positions for roles
export const P_A: Position = { x: 250, y: 450 }; // Central
export const P_B: Position = { x: 100, y: 450 }; // Breakside Side
export const P_C: Position = { x: 250, y: 200 }; // Cutter Middle
export const P_D: Position = { x: 100, y: 200 }; // Cutter Line
export const P_E: Position = { x: 250, y: 620 }; // Queue
export const getPositionsForStep = (cycle: number, step: number) => {
const roles = getCycleRoles(cycle);
const pos = {
[roles.A]: { ...P_A },
[roles.B]: { ...P_B },
[roles.C]: { ...P_C },
[roles.D]: { ...P_D },
[roles.E]: { ...P_E },
} as Record<PlayerId, Position>;
const discOffset = { x: 10, y: -10 };
let discHolder: PlayerId | null = roles.A;
let discX = P_A.x + discOffset.x;
let discY = P_A.y + discOffset.y;
let markX = P_A.x - 18;
let markY = P_A.y - 18;
let markAngle = -15; // Blocks space between both cutters
// 1: Initiate Cut
if (step >= 1) {
pos[roles.B] = { x: 250, y: 350 };
}
// 2: Fake Open Side
if (step >= 2) {
pos[roles.B] = { x: 350, y: 300 };
}
if (step === 2) {
discX = pos[roles.A].x + 35;
discY = pos[roles.A].y - 35;
// Mark bites slightly to challenge give and go cut
markX = pos[roles.A].x + 8;
markY = pos[roles.A].y - 15;
markAngle = 35; // Angles to block open side
}
// 3: Pivot & Break Cut
if (step >= 3 && step <= 4) {
discX = pos[roles.A].x - 28;
discY = pos[roles.A].y;
// Mark tries to recover but leaves lane open to the cutter
markX = P_A.x - 5;
markY = P_A.y - 25;
markAngle = -5; // Not fully rotated back to breakside angle
}
if (step === 3) {
// Cutter starts cut, but doesn't reach the end yet
pos[roles.D] = { x: 100, y: 220 };
}
if (step >= 4) {
// Cutter catches the disc further upfield, in stride
pos[roles.D] = { x: 100, y: 250 };
}
// 4: Throw Break
if (step === 4) {
discHolder = null;
discX = pos[roles.D].x;
discY = pos[roles.D].y;
}
// 5: Caught
if (step === 5) {
discHolder = roles.D;
discX = pos[roles.D].x + discOffset.x;
discY = pos[roles.D].y + discOffset.y;
}
// 6: Rotate
if (step === 6) {
pos[roles.A] = { ...P_B };
pos[roles.B] = { ...P_C };
pos[roles.C] = { ...P_D };
pos[roles.D] = { ...P_E };
pos[roles.E] = { ...P_A };
discHolder = roles.E;
discX = pos[roles.E].x + discOffset.x;
discY = pos[roles.E].y + discOffset.y;
markX = pos[roles.E].x - 18;
markY = pos[roles.E].y - 18;
markAngle = -15;
}
return { pos, discX, discY, markX, markY, markAngle };
};

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,20 @@
export type PlayerId = 1 | 2 | 3 | 4 | 5;
export type StepAction = {
name: string;
duration: number; // in ms
description: string;
};
export type PlayerRoles = {
A: PlayerId;
B: PlayerId;
C: PlayerId;
D: PlayerId;
E: PlayerId;
};
export type Position = {
x: number;
y: number;
};

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});