Compare commits

..

50 Commits

129 changed files with 3796 additions and 10629 deletions
+10
View File
@@ -0,0 +1,10 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
tab_width = 2
[*.{cs,vb}]
dotnet_diagnostic.CA1050.severity = none
-6
View File
@@ -1,6 +0,0 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}
+2
View File
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+12 -4
View File
@@ -1,5 +1,13 @@
**/node_modules
# Godot 4+ specific ignores
.godot/
/android/
.idea/
*.csproj
*.csproj*
*.csproj.old
*.csproj.old*
*.sln
*.sln.*
.DS_Store
**/.DS_Store
**/.next
**/connect4-moderator-server/
Folder.DotSettings.user
-4
View File
@@ -1,4 +0,0 @@
# AGENTS
- Use the TypeScript/TSX lint checker for validation instead of `npm run build`.
- Prefer `npm run lint` after TS/TSX changes unless the user explicitly asks for a different verification step.
-112
View File
@@ -1,112 +0,0 @@
# ============================================
# Stage 1: Dependencies Installation Stage
# ============================================
# IMPORTANT: Node.js Version Maintenance
# This Dockerfile uses Node.js 24.13.0-slim, which was the latest LTS version at the time of writing.
# To ensure security and compatibility, regularly update the NODE_VERSION ARG to the latest LTS version.
ARG NODE_VERSION=24.13.0-slim
FROM node:${NODE_VERSION} AS dependencies
# Set working directory
WORKDIR /app
# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
# Install project dependencies with frozen lockfile for reproducible builds
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/usr/local/share/.cache/yarn \
--mount=type=cache,target=/root/.local/share/pnpm/store \
if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm install --frozen-lockfile; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 2: Build Next.js application in standalone mode
# ============================================
FROM node:${NODE_VERSION} AS builder
# Set working directory
WORKDIR /app
# Copy project dependencies from dependencies stage
COPY --from=dependencies /app/node_modules ./node_modules
# Copy application source code
COPY . .
ENV NODE_ENV=production
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js application
# If you want to speed up Docker rebuilds, you can cache the build artifacts
# by adding: --mount=type=cache,target=/app/.next/cache
# This caches the .next/cache directory across builds, but it also prevents
# .next/cache/fetch-cache from being included in the final image, meaning
# cached fetch responses from the build won't be available at runtime.
RUN if [ -f package-lock.json ]; then \
npm run build; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm build; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 3: Run Next.js application
# ============================================
FROM node:${NODE_VERSION} AS runner
# Set working directory
WORKDIR /app
# Set production environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the run time.
# ENV NEXT_TELEMETRY_DISABLED=1
# Copy production assets
COPY --from=builder --chown=node:node /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown node:node .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
# If you want to persist the fetch cache generated during the build so that
# cached responses are available immediately on startup, uncomment this line:
# COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache
# Switch to non-root user for security best practices
USER node
# Expose port 3000 to allow HTTP traffic
EXPOSE 3000
# Start Next.js standalone server
CMD ["node", "server.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Joshua Higgins
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6 -100
View File
@@ -1,103 +1,9 @@
# Connect4 Moderator Observer UI
# Connect4 Moderator - Observer
A website interface for connecting to the [Connect4 WebSocket server](https://github.com/joshuafhiggins/connect4-moderator-server) as an observer or player, watching live matches, and managing tournaments.
The front end for the [server](https://github.com/joshuafhiggins/connect4-moderator-server) made in [Godot](https://godotengine.org/), an open-soruce game engine.
## Prerequisites
# Downloads
See [releases](https://github.com/joshuafhiggins/connect4-moderator-observer/releases)
- Linux/macOS terminal or Windows PowerShell
- Git
- Docker (optional, for containerized runs)
## Run Locally (Node.js + npm)
### 1) Install Node.js (includes npm)
#### Option A: Install with `nvm` (recommended, including Windows support)
On Linux/macOS:
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.zshrc
nvm install --lts
nvm use --lts
node -v
npm -v
```
On Windows (PowerShell), use `nvm-windows`:
```powershell
winget install CoreyButler.NVMforWindows
nvm install lts
nvm use lts
node -v
npm -v
```
### 2) Install dependencies
From the project root:
```bash
npm i
```
### 3) Start the UI in development mode
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
## Run with Docker
This repository includes:
- `Dockerfile` (multi-stage production build)
- `docker-compose.yml`
- `docker_build.sh`
### Build image directly from `Dockerfile`
```bash
docker build . -t joshuafhiggins/connect4-ui
```
or use the provided script:
```bash
./docker_build.sh
```
### Run container directly
```bash
docker run --rm -p 3000:3000 --name connect4-ui joshuafhiggins/connect4-ui
```
### Run with Docker Compose
```bash
docker compose up --build
```
Run detached:
```bash
docker compose up -d --build
```
Stop:
```bash
docker compose down
```
## Useful npm Scripts
- `npm run dev` - start Next.js dev server
- `npm run build` - build for production
- `npm run start` - run production build
- `npm run lint` - lint the project
# For Future Maintainers
This was made in Godot 4.5. Due to the use of C# for a lot of the scripting, we are unable to export for the web but [progress is being made](https://github.com/godotengine/godot/pull/106125). An icon in Icon Composer was made for macOS builds but can't be used till Godot 4.6. Currently, there is also a bug in Godot's Websocket implementation that causes random disconnects, options are submitting upstream fixes and debugging or wrapping a third party library to use. It would also be nice to rework the bracket screen to show the bracket and be more thematic with the other pixel art pieces. Some scripts are leftovers/incomplete implementations of having this program act as a client, which would be helpful to finish.
-15
View File
@@ -1,15 +0,0 @@
@import "tailwindcss";
:root {
--background: #030712;
--foreground: #f9fafb;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family:
system-ui,
-apple-system,
sans-serif;
}
-46
View File
@@ -1,46 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import Celebration from "@/components/Celebration";
import Nav from "@/components/Nav";
import PageTitleManager from "@/components/PageTitleManager";
import { ConnectionProvider } from "@/lib/connection";
import { CHIP_DROP_SOUND_PATHS } from "@/lib/sfx";
export const metadata: Metadata = {
description: "Watch matches, track tournaments, and play Connect4",
icons: {
icon: "/favicon.ico",
shortcut: "/favicon.ico",
apple: "/icon.png",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
{CHIP_DROP_SOUND_PATHS.map((path) => (
<link
key={path}
rel="preload"
href={path}
as="audio"
type="audio/ogg"
/>
))}
</head>
<body className="min-h-screen bg-gray-950 text-gray-100">
<ConnectionProvider>
<PageTitleManager />
<Celebration />
<Nav />
<main className="max-w-7xl mx-auto px-4 py-6">{children}</main>
</ConnectionProvider>
</body>
</html>
);
}
-83
View File
@@ -1,83 +0,0 @@
"use client";
import { SubmitEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { DEFAULT_WS_URL } from "@/lib/protocol";
import { useConnection } from "@/lib/connection";
export default function Home() {
const router = useRouter();
const {
connect,
role,
status,
wsUrl: connectedWsUrl,
shouldRedirectToConnect,
clearRedirectFlag,
} = useConnection();
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
useEffect(() => {
if (shouldRedirectToConnect) {
clearRedirectFlag();
}
}, [shouldRedirectToConnect, clearRedirectFlag]);
const onSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
connect({ role: "observer", wsUrl });
router.push("/spectate");
};
return (
<div className="max-w-3xl mx-auto py-10">
<div className="bg-gray-900 border border-gray-700 rounded-2xl p-6 md:p-8 flex flex-col gap-6">
<div>
<h1 className="text-3xl font-bold text-white">Connect to Server</h1>
<p className="text-sm text-gray-400 mt-2">
Connect as an observer to watch live matches and tournaments.
</p>
</div>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div>
<label className="text-xs text-gray-400 uppercase tracking-wider mb-1 block">
Server URL
</label>
<input
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
value={wsUrl}
onChange={(e) => setWsUrl(e.target.value)}
placeholder="wss://..."
/>
</div>
{shouldRedirectToConnect && (
<div className="rounded-lg border border-red-700 bg-red-950/40 px-4 py-3 text-sm text-red-200">
Connection lost. Please reconnect to continue.
</div>
)}
{status === "connected" && role && (
<div className="rounded-lg border border-green-700 bg-green-950/30 px-4 py-3 text-sm text-green-200">
Connected to {connectedWsUrl} as observer.
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
<button
type="submit"
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors"
>
{status === "connecting" || status === "reconnecting"
? "Connecting..."
: "Connect"}
</button>
</div>
</form>
</div>
</div>
);
}
-428
View File
@@ -1,428 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Confetti from "react-confetti";
import Board from "@/components/Board";
import {
BoardState,
ParsedMessage,
cmd,
createEmptyBoard,
placeToken,
} from "@/lib/protocol";
import { useConnection } from "@/lib/connection";
type GamePhase = "idle" | "connected" | "ready" | "playing" | "game-over";
type GameResult = "win" | "loss" | "draw" | "terminated";
export default function PlayPage() {
const router = useRouter();
const {
role,
username,
status,
isInMatch,
shouldGoFirst,
send,
subscribe,
reconnectAttempts,
shouldRedirectToConnect,
clearRedirectFlag,
} = useConnection();
const [gamePhase, setGamePhase] = useState<GamePhase>("idle");
const [myColor, setMyColor] = useState<1 | 2 | null>(null);
const [isMyTurn, setIsMyTurn] = useState(false);
const [board, setBoard] = useState<BoardState>(createEmptyBoard());
const [lastMove, setLastMove] = useState<{
column: number;
row: number;
} | null>(null);
const [gameResult, setGameResult] = useState<GameResult | null>(null);
const [tournamentMode, setTournamentMode] = useState(false);
const [showWinConfetti, setShowWinConfetti] = useState(false);
const [winConfettiBurstId, setWinConfettiBurstId] = useState(0);
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const myColorRef = useRef<1 | 2 | null>(null);
const isMyTurnRef = useRef(false);
const resetGame = useCallback(() => {
setBoard(createEmptyBoard());
setLastMove(null);
setMyColor(null);
myColorRef.current = null;
setIsMyTurn(false);
isMyTurnRef.current = false;
setGameResult(null);
}, []);
useEffect(() => {
const updateViewport = () => {
setViewport({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateViewport();
window.addEventListener("resize", updateViewport);
return () => window.removeEventListener("resize", updateViewport);
}, []);
useEffect(() => {
if (status === "disconnected" && shouldRedirectToConnect) {
clearRedirectFlag();
router.replace("/");
}
if (status === "idle") {
router.replace("/");
}
if (role !== "player" && status !== "idle") {
router.replace("/spectate");
return;
}
if (status === "connected" && gamePhase === "idle") {
// Mid-match reconnect can remount with phase idle while still in a match; avoid
// the pre-queue "connected" / Ready Up state until we know we are not in-game.
setGamePhase(isInMatch ? "playing" : "connected");
if (isInMatch) {
const color: 1 | 2 = shouldGoFirst ? 1 : 2;
setMyColor(color);
myColorRef.current = color;
setIsMyTurn(shouldGoFirst);
isMyTurnRef.current = shouldGoFirst;
}
}
}, [
role,
status,
router,
gamePhase,
isInMatch,
shouldRedirectToConnect,
clearRedirectFlag,
]);
useEffect(() => {
const unsubscribe = subscribe((msg: ParsedMessage) => {
switch (msg.type) {
case "CONNECT_ACK":
case "RECONNECT_ACK":
setGamePhase((prev) => {
if (prev !== "idle") return prev;
if (isInMatch) return "playing";
return "connected";
});
break;
case "ERROR":
break;
case "READY_ACK":
setGamePhase("ready");
break;
case "GAME_START": {
resetGame();
const color: 1 | 2 = msg.goesFirst ? 1 : 2;
setMyColor(color);
myColorRef.current = color;
setGamePhase("playing");
const firstTurn = msg.goesFirst;
setIsMyTurn(firstTurn);
isMyTurnRef.current = firstTurn;
break;
}
case "OPPONENT_MOVE": {
const opponentColor: 1 | 2 = myColorRef.current === 1 ? 2 : 1;
setBoard((prev) => {
const { board: next, row } = placeToken(
prev,
opponentColor,
msg.column,
);
setLastMove({ column: msg.column, row });
return next;
});
setIsMyTurn(true);
isMyTurnRef.current = true;
break;
}
case "GAME_WINS":
setGameResult("win");
setWinConfettiBurstId((prev) => prev + 1);
setShowWinConfetti(true);
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
break;
case "GAME_LOSS":
setGameResult("loss");
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
break;
case "GAME_DRAW":
setGameResult("draw");
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
break;
case "GAME_TERMINATED":
setGameResult("terminated");
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
break;
case "TOURNAMENT_START":
setTournamentMode(true);
break;
case "TOURNAMENT_END":
setGamePhase("connected");
resetGame();
send(cmd.ready());
setGamePhase("ready");
break;
case "TOURNAMENT_CANCEL":
setTournamentMode(false);
setGamePhase("connected");
resetGame();
break;
default:
break;
}
});
return unsubscribe;
}, [resetGame, send, subscribe, isInMatch, username]);
const handleColumnClick = useCallback(
(col: number) => {
if (!isMyTurnRef.current || gamePhase !== "playing") return;
const color = myColorRef.current;
if (!color) return;
setBoard((prev) => {
const { board: next, row } = placeToken(prev, color, col);
if (row === -1) return prev;
setLastMove({ column: col, row });
return next;
});
setIsMyTurn(false);
isMyTurnRef.current = false;
send(cmd.play(col));
},
[gamePhase, send],
);
const sendReady = useCallback(() => {
send(cmd.ready());
setGamePhase("ready");
}, [send]);
const myColorLabel =
myColor === 1 ? "🔴 Red" : myColor === 2 ? "🟡 Yellow" : null;
const opponentColor: 1 | 2 | null =
myColor === 1 ? 2 : myColor === 2 ? 1 : null;
const redPlayerName = myColor === 1 ? username : "Opponent";
const yellowPlayerName = myColor === 2 ? username : "Opponent";
return (
<div className="flex flex-col gap-6">
{showWinConfetti && (
<Confetti
key={winConfettiBurstId}
width={viewport.width}
height={viewport.height}
recycle={false}
onConfettiComplete={() => setShowWinConfetti(false)}
numberOfPieces={300}
gravity={0.28}
className="pointer-events-none fixed! inset-0! z-40!"
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">🎮 Play Connect4</h1>
<p className="text-gray-400 text-sm mt-1">
Connected as <span className="text-green-300">{username}</span>
</p>
</div>
<div className="flex items-center gap-3">
{gamePhase === "connected" && (
<button
onClick={sendReady}
className="rounded-full border border-green-700 bg-green-900/60 px-3 py-1.5 text-sm font-medium text-green-300 transition-colors hover:bg-green-800/70"
>
Ready Up
</button>
)}
<PhaseIndicator phase={gamePhase} isMyTurn={isMyTurn} />
</div>
</div>
{status === "reconnecting" && (
<div className="rounded-lg border border-yellow-700 bg-yellow-950/30 px-4 py-3 text-sm text-yellow-200">
Connection lost during a live match. Reconnect attempt #
{reconnectAttempts}...
</div>
)}
{tournamentMode && (
<div className="flex items-center gap-2 rounded-lg border border-purple-700 bg-purple-950/50 px-3 py-2 text-sm text-purple-300">
<span>🏆</span>
<span>Tournament mode active</span>
</div>
)}
{(gamePhase === "playing" || gamePhase === "game-over") && myColor && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg bg-gray-800 px-3 py-2">
<span className="text-sm font-medium text-white">
You are {myColorLabel}
</span>
</div>
{gamePhase === "playing" && (
<div
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold ${
isMyTurn
? "animate-pulse border border-green-600 bg-green-900/50 text-green-300"
: "bg-gray-800 text-gray-400"
}`}
>
{isMyTurn
? "⬆ Your turn - click a column"
: "⏳ Waiting for opponent..."}
</div>
)}
</div>
)}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center gap-4 min-h-96">
{gamePhase === "idle" ? (
<div className="text-gray-500 text-center py-10">
Connect from the connection page to start.
</div>
) : gamePhase === "connected" ? (
<div className="flex flex-col items-center gap-4 text-center py-8">
<div className="text-5xl"></div>
<p className="text-blue-300 text-lg font-medium">
Ready up to start
</p>
<p className="text-gray-500 text-sm max-w-sm">
Click the{" "}
<span className="text-green-300 font-semibold">
Ready Up
</span>{" "}
button beside your status to enter the queue.
</p>
</div>
) : gamePhase === "ready" ? (
<div className="flex flex-col items-center gap-4 text-center py-8">
<div className="text-5xl animate-bounce"></div>
<p className="text-yellow-300 text-lg font-medium">
Waiting for an opponent...
</p>
</div>
) : (
<>
{gameResult && (
<div
className={`w-full max-w-md rounded-xl p-4 text-center font-bold text-xl border ${
gameResult === "win"
? "bg-green-900/50 border-green-500 text-green-300"
: gameResult === "loss"
? "bg-red-900/50 border-red-500 text-red-300"
: gameResult === "draw"
? "bg-blue-900/50 border-blue-500 text-blue-300"
: "bg-gray-800 border-gray-600 text-gray-300"
}`}
>
{gameResult === "win"
? "🏆 You Won!"
: gameResult === "loss"
? "💔 You Lost"
: gameResult === "draw"
? "🤝 Draw"
: "⛔ Match Terminated"}
</div>
)}
<Board
board={board}
lastMove={lastMove}
player1={redPlayerName}
player2={yellowPlayerName}
currentTurnColor={
gamePhase === "playing" && myColor
? isMyTurn
? myColor
: opponentColor
: null
}
onColumnClick={
gamePhase === "playing" && isMyTurn
? handleColumnClick
: undefined
}
disabled={gamePhase !== "playing" || !isMyTurn}
/>
</>
)}
</div>
</div>
);
}
function PhaseIndicator({
phase,
isMyTurn,
}: {
phase: GamePhase;
isMyTurn: boolean;
}) {
if (phase === "playing" && isMyTurn) {
return (
<span className="px-3 py-1.5 rounded-full text-sm font-medium bg-green-900/60 text-green-300">
Your Turn!
</span>
);
}
const config: Record<GamePhase, { label: string; cls: string }> = {
idle: { label: "Not ready", cls: "bg-gray-700 text-gray-400" },
connected: { label: "Connected", cls: "bg-blue-900/60 text-blue-300" },
ready: {
label: "Waiting...",
cls: "bg-yellow-900/60 text-yellow-300 animate-pulse",
},
playing: { label: "In Game", cls: "bg-green-900/60 text-green-400" },
"game-over": { label: "Game Over", cls: "bg-gray-700 text-gray-400" },
};
const { label, cls } = config[phase];
return (
<span className={`px-3 py-1.5 rounded-full text-sm font-medium ${cls}`}>
{label}
</span>
);
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://5pk6vnctpyaf"
path="res://.godot/imported/PixelOperator8-Bold.ttf-74faf550739674ad3170f08e646e0614.fontdata"
[deps]
source_file="res://assets/fonts/PixelOperator8-Bold.ttf"
dest_files=["res://.godot/imported/PixelOperator8-Bold.ttf-74faf550739674ad3170f08e646e0614.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://c3jmev24lo6ci"
path="res://.godot/imported/PixelOperator8.ttf-6f9f01766aff16f52046b880ffb8d367.fontdata"
[deps]
source_file="res://assets/fonts/PixelOperator8.ttf"
dest_files=["res://.godot/imported/PixelOperator8.ttf-6f9f01766aff16f52046b880ffb8d367.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+1
View File
@@ -0,0 +1 @@
jazz_music.mp3 filter=lfs diff=lfs merge=lfs -text
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://csy7ltflvsjq5"
path="res://.godot/imported/jazz_music.mp3-51c0488dbe42064eda25eafa56d387ed.mp3str"
[deps]
source_file="res://assets/music/jazz_music.mp3"
dest_files=["res://.godot/imported/jazz_music.mp3-51c0488dbe42064eda25eafa56d387ed.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dauf0pi1pkd3x"
path="res://.godot/imported/chip_collide_1.ogg-406dd38ea9c3d5652eebd8457cb16d54.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_1.ogg"
dest_files=["res://.godot/imported/chip_collide_1.ogg-406dd38ea9c3d5652eebd8457cb16d54.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b6b7b7sc038n7"
path="res://.godot/imported/chip_collide_2.ogg-f3e4af1639a40a7e5696a0d76240e083.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_2.ogg"
dest_files=["res://.godot/imported/chip_collide_2.ogg-f3e4af1639a40a7e5696a0d76240e083.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c6a4wqoopu53j"
path="res://.godot/imported/chip_collide_3.ogg-8dacdc0da1447d7f98373dc814c23342.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_3.ogg"
dest_files=["res://.godot/imported/chip_collide_3.ogg-8dacdc0da1447d7f98373dc814c23342.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://5liedrachob2"
path="res://.godot/imported/chip_collide_4.ogg-a636adca2293a212ad4dd420b3445fd5.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_4.ogg"
dest_files=["res://.godot/imported/chip_collide_4.ogg-a636adca2293a212ad4dd420b3445fd5.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dsyovynhhbmw8"
path="res://.godot/imported/chip_collide_5.ogg-43d68269cdbbab204cb581d3f2f55acc.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_5.ogg"
dest_files=["res://.godot/imported/chip_collide_5.ogg-43d68269cdbbab204cb581d3f2f55acc.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c0a5rl5q04noq"
path="res://.godot/imported/chip_collide_6.ogg-277685742efbfc8894a8af489e0a5d40.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_6.ogg"
dest_files=["res://.godot/imported/chip_collide_6.ogg-277685742efbfc8894a8af489e0a5d40.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b6d73wiiqxles"
path="res://.godot/imported/chip_collide_7.ogg-7f61ae7f863bd72768f0889e7ed11946.oggvorbisstr"
[deps]
source_file="res://assets/sfx/chip_collide_7.ogg"
dest_files=["res://.godot/imported/chip_collide_7.ogg-7f61ae7f863bd72768f0889e7ed11946.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://crxjuk1vyq331"
path="res://.godot/imported/game_end.ogg-8ceab2b7181c15a955c6f20334947ba7.oggvorbisstr"
[deps]
source_file="res://assets/sfx/game_end.ogg"
dest_files=["res://.godot/imported/game_end.ogg-8ceab2b7181c15a955c6f20334947ba7.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dlx02qat7j6lf"
path="res://.godot/imported/AssetTileset.png-61cab4eb65e8853e2f236960720774cb.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/AssetTileset.png"
dest_files=["res://.godot/imported/AssetTileset.png-61cab4eb65e8853e2f236960720774cb.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://8un28mol7qow"
path="res://.godot/imported/BoardTileMap.png-b52c6a70f2625846356fb1557a154ce5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/BoardTileMap.png"
dest_files=["res://.godot/imported/BoardTileMap.png-b52c6a70f2625846356fb1557a154ce5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ckmfi0cjgxgyk"
path="res://.godot/imported/RedChip.png-87cfb9e74b846c07f18c0c7fe300f504.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/RedChip.png"
dest_files=["res://.godot/imported/RedChip.png-87cfb9e74b846c07f18c0c7fe300f504.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://qy30emdgrk7o"
path="res://.godot/imported/YellowChip.png-a8245744c0582bab34b9b96583a57ec0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/YellowChip.png"
dest_files=["res://.godot/imported/YellowChip.png-a8245744c0582bab34b9b96583a57ec0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2plbtkcttn7o"
path="res://.godot/imported/back.png-73d0d71f353725c6ba9218045b96399b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/back.png"
dest_files=["res://.godot/imported/back.png-73d0d71f353725c6ba9218045b96399b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdhuhbt2ws5sy"
path="res://.godot/imported/bracket.png-31c6864ee533d160bca76e6c85f99c4e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/bracket.png"
dest_files=["res://.godot/imported/bracket.png-31c6864ee533d160bca76e6c85f99c4e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cftuywdnwvop0"
path="res://.godot/imported/button.png-01faf565b773239305f3664038f20e61.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/button.png"
dest_files=["res://.godot/imported/button.png-01faf565b773239305f3664038f20e61.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://stk7umv2ppss"
path="res://.godot/imported/cancel.png-dcc5e0579b1b7ac5745b3b3804e100ab.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/cancel.png"
dest_files=["res://.godot/imported/cancel.png-dcc5e0579b1b7ac5745b3b3804e100ab.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bvind4dms08sd"
path="res://.godot/imported/long_button.png-97ae4bec79a5441e5b3d9a75cbdf673f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/long_button.png"
dest_files=["res://.godot/imported/long_button.png-97ae4bec79a5441e5b3d9a75cbdf673f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://da13ksuf4vkqe"
path="res://.godot/imported/observe.png-5a0a3c79f51f4f13ffdfce42e4ad04da.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/observe.png"
dest_files=["res://.godot/imported/observe.png-5a0a3c79f51f4f13ffdfce42e4ad04da.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6iac21s36dku"
path="res://.godot/imported/player.png-e1eaffe0873063c60a0d0b322e4d87d9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/player.png"
dest_files=["res://.godot/imported/player.png-e1eaffe0873063c60a0d0b322e4d87d9.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dta0orurmv5qj"
path="res://.godot/imported/player_badge.png-63850d039b551977db5c0c02f6402465.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/player_badge.png"
dest_files=["res://.godot/imported/player_badge.png-63850d039b551977db5c0c02f6402465.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://uritd4ygetrk"
path="res://.godot/imported/rpi.png-0f0faa9ccfa1d0b656d9c381bb4a7a6d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/rpi.png"
dest_files=["res://.godot/imported/rpi.png-0f0faa9ccfa1d0b656d9c381bb4a7a6d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+6
View File
@@ -0,0 +1,6 @@
[gd_resource type="Theme" load_steps=2 format=3 uid="uid://bbgxacei1vwba"]
[ext_resource type="FontFile" uid="uid://c3jmev24lo6ci" path="res://assets/fonts/PixelOperator8.ttf" id="1_5x3i2"]
[resource]
default_font = ExtResource("1_5x3i2")
-727
View File
@@ -1,727 +0,0 @@
"use client";
import { FormEvent, useEffect, useRef, useState } from "react";
import { Plus, RefreshCw, Save, Trash2 } from "lucide-react";
import { cmd, ParsedMessage, ReservationEntry } from "@/lib/protocol";
import { useConnection } from "@/lib/connection";
const GETTABLE_DATA_KEYS = [
"TOURNAMENT_STATUS",
"TOURNAMENT_DATA",
"MOVE_WAIT",
"DEMO_MODE",
"MAX_TIMEOUT",
"BRACKET_PAIRINGS",
] as const;
const EDITABLE_DATA_KEYS = ["DEMO_MODE", "MAX_TIMEOUT", "MOVE_WAIT"] as const;
const TOURNAMENT_TYPES = ["RoundRobin", "KnockoutBracket"] as const;
const TOURNAMENT_TYPE_LABELS: Record<
(typeof TOURNAMENT_TYPES)[number],
string
> = {
RoundRobin: "Round Robin",
KnockoutBracket: "Knockout Bracket",
};
const VARIABLE_LABELS: Record<(typeof EDITABLE_DATA_KEYS)[number], string> = {
DEMO_MODE: "Demo Mode",
MAX_TIMEOUT: "Max Timeout",
MOVE_WAIT: "Move Wait",
};
function parseBracketPairings(value: string): string[] {
return value
.split(",")
.map((username) => username.trim())
.filter((username) => username.length > 0);
}
function serializeBracketPairings(players: string[]): string {
return players.join(",");
}
function formatTournamentType(type: string): string {
if (type === "RoundRobin" || type === "KnockoutBracket") {
return TOURNAMENT_TYPE_LABELS[type];
}
return type;
}
export default function AdminSettingsPanel() {
const { authenticateAdmin, isAdmin, send, status, subscribe } =
useConnection();
const [adminPassword, setAdminPassword] = useState("");
const [adminFeedback, setAdminFeedback] = useState<string | null>(null);
const [selectedTournamentType, setSelectedTournamentType] =
useState<(typeof TOURNAMENT_TYPES)[number]>("RoundRobin");
const [serverValues, setServerValues] = useState<Record<string, string>>({});
const [editableValues, setEditableValues] = useState<Record<string, string>>(
{},
);
const [bracketPlayer, setBracketPlayer] = useState("");
const [bracketPairings, setBracketPairings] = useState<string[]>([]);
const [reservationPlayer1, setReservationPlayer1] = useState("");
const [reservationPlayer2, setReservationPlayer2] = useState("");
const [reservations, setReservations] = useState<ReservationEntry[]>([]);
const [actionFeedback, setActionFeedback] = useState<string | null>(null);
const pendingAdminAuthRef = useRef(false);
const pendingSetRef = useRef<Record<string, string>>({});
const isConnected = status === "connected";
const adminControlsDisabled = !isConnected || !isAdmin;
const hasActiveTournament = Boolean(
serverValues.TOURNAMENT_STATUS &&
serverValues.TOURNAMENT_STATUS !== "false",
);
const hasVariableChanges = EDITABLE_DATA_KEYS.some(
(key) =>
editableValues[key] !== undefined &&
editableValues[key] !== serverValues[key],
);
const serializedBracketPairings = serializeBracketPairings(bracketPairings);
const hasBracketPairingChanges =
serializedBracketPairings !== (serverValues.BRACKET_PAIRINGS ?? "");
useEffect(() => {
const unsubscribe = subscribe((message: ParsedMessage) => {
switch (message.type) {
case "ADMIN_AUTH_ACK":
pendingAdminAuthRef.current = false;
setAdminFeedback("Admin access granted.");
break;
case "GET_DATA":
setServerValues((prev) => ({
...prev,
[message.key]: message.value,
}));
if (
EDITABLE_DATA_KEYS.includes(
message.key as (typeof EDITABLE_DATA_KEYS)[number],
)
) {
setEditableValues((prev) => ({
...prev,
[message.key]: message.value,
}));
}
if (message.key === "BRACKET_PAIRINGS") {
setBracketPairings(parseBracketPairings(message.value));
}
setActionFeedback(`Loaded ${message.key}.`);
break;
case "SET_DATA_ACK":
setServerValues((prev) => ({
...prev,
[message.key]:
pendingSetRef.current[message.key] ?? prev[message.key] ?? "",
}));
setEditableValues((prev) => ({
...prev,
[message.key]:
pendingSetRef.current[message.key] ?? prev[message.key] ?? "",
}));
if (message.key === "BRACKET_PAIRINGS") {
setBracketPairings(
parseBracketPairings(pendingSetRef.current[message.key] ?? ""),
);
}
delete pendingSetRef.current[message.key];
setActionFeedback(`Updated ${message.key}.`);
break;
case "TOURNAMENT_START":
setServerValues((prev) => ({
...prev,
TOURNAMENT_STATUS: message.tournamentType,
}));
setActionFeedback(
`Started ${formatTournamentType(message.tournamentType)}.`,
);
break;
case "TOURNAMENT_CANCEL":
setServerValues((prev) => ({
...prev,
TOURNAMENT_STATUS: "false",
}));
setActionFeedback("Tournament cancelled.");
break;
case "TOURNAMENT_END":
setServerValues((prev) => ({
...prev,
TOURNAMENT_STATUS: "false",
}));
break;
case "RESERVATION_LIST":
setReservations(message.reservations);
setActionFeedback("Loaded reservations.");
break;
case "RESERVATION_ADD":
setReservations((prev) => {
const exists = prev.some(
(reservation) =>
reservation.player1 === message.player1 &&
reservation.player2 === message.player2,
);
return exists
? prev
: [
...prev,
{ player1: message.player1, player2: message.player2 },
];
});
setActionFeedback(null);
break;
case "RESERVATION_DELETE":
setReservations((prev) =>
prev.filter(
(reservation) =>
!(
reservation.player1 === message.player1 &&
reservation.player2 === message.player2
),
),
);
setActionFeedback(null);
break;
case "ERROR":
if (pendingAdminAuthRef.current) {
pendingAdminAuthRef.current = false;
setAdminFeedback("Admin authentication failed.");
}
setActionFeedback(message.message);
break;
default:
break;
}
});
return unsubscribe;
}, [subscribe]);
useEffect(() => {
if (!isAdmin || !isConnected) {
return;
}
GETTABLE_DATA_KEYS.forEach((key) => {
send(cmd.getData(key));
});
send(cmd.reservationGet());
}, [isAdmin, isConnected, send]);
const handleAdminAuth = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const sent = authenticateAdmin(adminPassword);
pendingAdminAuthRef.current = sent;
setAdminFeedback(
sent
? "Authenticating admin session..."
: "Connect before authenticating.",
);
if (sent) {
setAdminPassword("");
}
};
const handleTournamentStart = () => {
if (hasActiveTournament) {
setActionFeedback("A tournament is already active.");
return;
}
if (!send(cmd.tournamentStart(selectedTournamentType))) {
setActionFeedback("Unable to start tournament while disconnected.");
return;
}
setActionFeedback(
`Starting ${formatTournamentType(selectedTournamentType)} tournament...`,
);
};
const handleTournamentCancel = () => {
if (!hasActiveTournament) {
setActionFeedback("No active tournament to cancel.");
return;
}
if (!send(cmd.tournamentCancel())) {
setActionFeedback("Unable to cancel tournament while disconnected.");
return;
}
setActionFeedback("Cancelling tournament...");
};
const handleEditableValueChange = (key: string, value: string) => {
setEditableValues((prev) => ({
...prev,
[key]: value,
}));
};
const handleSaveVariables = () => {
const changes = EDITABLE_DATA_KEYS.filter(
(key) =>
editableValues[key] !== undefined &&
editableValues[key] !== serverValues[key],
);
if (changes.length === 0) {
setActionFeedback("No variable changes to save.");
return;
}
for (const key of changes) {
const value = editableValues[key];
if (!send(cmd.setData(key, value))) {
setActionFeedback("Unable to send SET command while disconnected.");
return;
}
pendingSetRef.current[key] = value;
}
setActionFeedback(
`Saving ${changes.length} variable${changes.length === 1 ? "" : "s"}...`,
);
};
const handleReservationAction = (action: "add" | "delete") => {
const player1 = reservationPlayer1.trim();
const player2 = reservationPlayer2.trim();
if (!player1 || !player2) return;
const message =
action === "add"
? cmd.reservationAdd(player1, player2)
: cmd.reservationDelete(player1, player2);
const sent = send(message);
if (!sent) {
setActionFeedback(
"Unable to send reservation command while disconnected.",
);
return;
}
setActionFeedback(
`${action === "add" ? "Adding" : "Removing"} reservation for ${player1} vs ${player2}...`,
);
setReservationPlayer1("");
setReservationPlayer2("");
};
const handleReservationDeletePair = (player1: string, player2: string) => {
const sent = send(cmd.reservationDelete(player1, player2));
if (!sent) {
setActionFeedback(
"Unable to send reservation command while disconnected.",
);
return;
}
setActionFeedback(`Removing reservation for ${player1} vs ${player2}...`);
};
const handleRefreshReservations = () => {
if (!send(cmd.reservationGet())) {
setActionFeedback("Unable to fetch reservations while disconnected.");
return;
}
setActionFeedback("Fetching reservations...");
};
const handleBracketPairingAdd = () => {
const player = bracketPlayer.trim();
if (!player) return;
setBracketPairings((prev) => [...prev, player]);
setBracketPlayer("");
};
const handleBracketPairingDelete = (index: number) => {
setBracketPairings((prev) =>
prev.filter((_, pairingIndex) => pairingIndex !== index),
);
};
const handleRefreshBracketPairings = () => {
if (!send(cmd.getData("BRACKET_PAIRINGS"))) {
setActionFeedback("Unable to fetch bracket pairings while disconnected.");
return;
}
setActionFeedback("Fetching bracket pairings...");
};
const handleSaveBracketPairings = () => {
if (!hasBracketPairingChanges) {
setActionFeedback("No bracket pairing changes to save.");
return;
}
if (!send(cmd.setData("BRACKET_PAIRINGS", serializedBracketPairings))) {
setActionFeedback("Unable to send SET command while disconnected.");
return;
}
pendingSetRef.current.BRACKET_PAIRINGS = serializedBracketPairings;
setActionFeedback("Saving bracket pairings...");
};
return (
<section className="rounded-xl flex flex-col gap-4">
{!isAdmin && (
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
Authenticate
</h2>
<p className="mt-1 text-sm text-gray-400">
Become an admin to manage tournaments, server variables, and
reservations.
</p>
<form className="mt-4 flex flex-col gap-3" onSubmit={handleAdminAuth}>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="password"
value={adminPassword}
onChange={(event) => setAdminPassword(event.target.value)}
placeholder="Password"
disabled={!isConnected}
className="w-[50%] rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={!isConnected}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-500"
>
Login
</button>
</div>
</form>
{adminFeedback && (
<div className="mt-3 rounded-lg border border-blue-800 bg-blue-950/30 px-3 py-2 text-sm text-blue-200">
{adminFeedback}
</div>
)}
</div>
)}
{isAdmin && (
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<h3 className="text-sm font-semibold text-white">Tournaments</h3>
<p className="mt-1 text-sm text-gray-400">
Start or cancel tournaments from the live spectator view.
</p>
<div className="mt-4 flex flex-col gap-3">
<select
onChange={(event) =>
setSelectedTournamentType(
event.target.value as (typeof TOURNAMENT_TYPES)[number],
)
}
disabled={adminControlsDisabled || hasActiveTournament}
value={
hasActiveTournament &&
(serverValues.TOURNAMENT_STATUS === "RoundRobin" ||
serverValues.TOURNAMENT_STATUS === "KnockoutBracket")
? (serverValues.TOURNAMENT_STATUS as (typeof TOURNAMENT_TYPES)[number])
: selectedTournamentType
}
className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
>
{TOURNAMENT_TYPES.map((type) => (
<option key={type} value={type}>
{TOURNAMENT_TYPE_LABELS[type]}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={handleTournamentStart}
disabled={adminControlsDisabled || hasActiveTournament}
className="flex-1 rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-green-500 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-500"
>
Start Tournament
</button>
<button
type="button"
onClick={handleTournamentCancel}
disabled={adminControlsDisabled || !hasActiveTournament}
className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-500 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-500"
>
Cancel
</button>
</div>
<div className="mt-6">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-white">
Bracket Pairings
</h4>
<p className="mt-1 text-sm text-gray-400">
Use custom seeding for knockout bracket.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleRefreshBracketPairings}
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Refresh bracket pairings"
title="Refresh bracket pairings"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleSaveBracketPairings}
disabled={
adminControlsDisabled || !hasBracketPairingChanges
}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Save bracket pairings"
title="Save bracket pairings"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
<form
className="mt-4 grid gap-3 md:grid-cols-[1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
handleBracketPairingAdd();
}}
>
<input
value={bracketPlayer}
onChange={(event) => setBracketPlayer(event.target.value)}
disabled={adminControlsDisabled}
placeholder="Player"
className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Add bracket player"
title="Add bracket player"
>
<Plus className="h-4 w-4" />
</button>
</form>
<div className="mt-4 flex flex-col">
{bracketPairings.length === 0 ? (
<p className="text-sm text-gray-500">
No bracket players added.
</p>
) : (
<div className="border border-gray-800 bg-[#050A16] rounded-lg">
{bracketPairings.map((player, index) => (
<div
key={`${player}-${index}`}
className="flex items-center justify-between rounded-lg bg-[#050A16] px-3 py-2 text-sm"
>
<span className="text-white">{player}</span>
<button
type="button"
onClick={() => handleBracketPairingDelete(index)}
disabled={adminControlsDisabled}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-800 hover:text-red-300 disabled:cursor-not-allowed disabled:text-gray-600"
aria-label={`Delete bracket player ${player}`}
title="Delete bracket player"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">
Server Variables
</h3>
<p className="mt-1 text-sm text-gray-400">
Modify server settings and variables.
</p>
</div>
<button
type="button"
onClick={() => {
EDITABLE_DATA_KEYS.forEach((key) => {
send(cmd.getData(key));
});
setActionFeedback("Refreshing server variables...");
}}
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Refresh server variables"
title="Refresh server variables"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex flex-col gap-4">
<div className="rounded-lg border border-gray-800 bg-gray-900/80 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="text-gray-400">
{VARIABLE_LABELS.DEMO_MODE}
</div>
<input
type="checkbox"
checked={editableValues.DEMO_MODE === "true"}
onChange={(event) =>
handleEditableValueChange(
"DEMO_MODE",
event.target.checked ? "true" : "false",
)
}
disabled={adminControlsDisabled}
className="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500 disabled:cursor-not-allowed"
/>
</div>
</div>
{(["MAX_TIMEOUT", "MOVE_WAIT"] as const).map((key) => (
<div
key={key}
className="rounded-lg border border-gray-800 bg-gray-900/80 px-4 py-3"
>
<div className="flex items-center gap-4">
<div className="min-w-32 shrink-0 text-gray-400">
{VARIABLE_LABELS[key]}
</div>
<input
value={editableValues[key] ?? ""}
onChange={(event) =>
handleEditableValueChange(key, event.target.value)
}
disabled={adminControlsDisabled}
className="w-32 rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:w-40"
/>
<span className="shrink-0 text-sm text-gray-400">s</span>
</div>
</div>
))}
<div className="flex justify-end">
<button
type="button"
onClick={handleSaveVariables}
disabled={adminControlsDisabled || !hasVariableChanges}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Save server variables"
title="Save server variables"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)}
{isAdmin && (
<div className="rounded-xl border border-gray-700 bg-[#0B111F] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">Reservations</h3>
<p className="mt-1 text-sm text-gray-400">
Create, remove, and review match reservations for specific
players.
</p>
</div>
<button
type="button"
onClick={handleRefreshReservations}
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Refresh reservations"
title="Refresh reservations"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<form
className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]"
onSubmit={(event) => {
event.preventDefault();
handleReservationAction("add");
}}
>
<input
value={reservationPlayer1}
onChange={(event) => setReservationPlayer1(event.target.value)}
disabled={adminControlsDisabled}
placeholder="Player 1"
className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<input
value={reservationPlayer2}
onChange={(event) => setReservationPlayer2(event.target.value)}
disabled={adminControlsDisabled}
placeholder="Player 2"
className="rounded-lg border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={adminControlsDisabled}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition-colors hover:bg-gray-700 hover:text-blue-300 disabled:cursor-not-allowed disabled:bg-gray-800 disabled:text-gray-600"
aria-label="Add reservation"
title="Add reservation"
>
<Plus className="h-4 w-4" />
</button>
</form>
<div className="mt-4 flex flex-col gap-2">
{reservations.length === 0 ? (
<p className="text-sm text-gray-500">No reservations exist.</p>
) : (
reservations.map((reservation) => (
<div
key={`${reservation.player1}-${reservation.player2}`}
className="flex items-center justify-between rounded-lg border border-gray-800 bg-gray-900/80 px-3 py-2 text-sm"
>
<span className="text-white">
{reservation.player1} vs {reservation.player2}
</span>
<button
type="button"
onClick={() =>
handleReservationDeletePair(
reservation.player1,
reservation.player2,
)
}
disabled={adminControlsDisabled}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-800 hover:text-red-300 disabled:cursor-not-allowed disabled:text-gray-600"
aria-label={`Delete reservation for ${reservation.player1} versus ${reservation.player2}`}
title="Delete reservation"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))
)}
</div>
</div>
)}
{actionFeedback && !actionFeedback.startsWith("Loaded ") && (
<div className="rounded-lg border border-gray-700 bg-gray-950/80 px-3 py-2 text-sm text-gray-200">
{actionFeedback}
</div>
)}
</section>
);
}
-252
View File
@@ -1,252 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import type { BoardState } from "@/lib/protocol";
import { CHIP_DROP_SOUND_PATHS } from "@/lib/sfx";
const CHIP_DROP_ANIMATION_MS = 450;
interface BoardProps {
board: BoardState;
onColumnClick?: (column: number) => void;
disabled?: boolean;
lastMove?: { column: number; row: number } | null;
player1?: string;
player2?: string;
currentTurnColor?: 1 | 2 | null;
}
export default function Board({
board,
onColumnClick,
disabled = false,
lastMove,
player1,
player2,
currentTurnColor,
}: BoardProps) {
const [hoveredCol, setHoveredCol] = useState<number | null>(null);
const [animatingMove, setAnimatingMove] = useState<{
column: number;
row: number;
} | null>(null);
const isFirstRender = useRef(true);
const animationTimeoutRef = useRef<number | null>(null);
const chipDropSoundsRef = useRef<HTMLAudioElement[]>([]);
const canInteract = !disabled && !!onColumnClick;
const lastMoveColumn = lastMove?.column ?? null;
const lastMoveRow = lastMove?.row ?? null;
useEffect(() => {
chipDropSoundsRef.current = CHIP_DROP_SOUND_PATHS.map((path) => {
const sound = new Audio(path);
sound.preload = "auto";
return sound;
});
return () => {
chipDropSoundsRef.current.forEach((sound) => {
sound.pause();
sound.src = "";
});
chipDropSoundsRef.current = [];
};
}, []);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (animationTimeoutRef.current !== null) {
window.clearTimeout(animationTimeoutRef.current);
}
if (lastMoveColumn === null || lastMoveRow === null) {
setAnimatingMove(null);
return;
}
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const dropDurationMs = prefersReducedMotion ? 0 : CHIP_DROP_ANIMATION_MS;
setAnimatingMove({ column: lastMoveColumn, row: lastMoveRow });
animationTimeoutRef.current = window.setTimeout(() => {
const sounds = chipDropSoundsRef.current;
const sound = sounds[Math.floor(Math.random() * sounds.length)];
if (sound) {
sound.currentTime = 0;
void sound.play().catch(() => {
// Ignore autoplay failures until the user has interacted.
});
}
setAnimatingMove(null);
animationTimeoutRef.current = null;
}, dropDurationMs);
return () => {
if (animationTimeoutRef.current !== null) {
window.clearTimeout(animationTimeoutRef.current);
}
};
}, [lastMoveColumn, lastMoveRow]);
return (
<div className="flex flex-col items-center gap-3">
{/* Player legend */}
{player1 && player2 && (
<div className="flex items-center gap-6 text-sm">
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
currentTurnColor === 1
? "border-red-500 bg-red-950/50 text-red-300"
: "border-gray-700 bg-gray-900 text-gray-400"
}`}
>
{currentTurnColor === 1 ? (
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0 animate-pulse" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0" />
)}
<span className="font-medium">{player1}</span>
</div>
<span className="text-gray-600 font-bold">vs</span>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
currentTurnColor === 2
? "border-yellow-500 bg-yellow-950/50 text-yellow-300"
: "border-gray-700 bg-gray-900 text-gray-400"
}`}
>
{currentTurnColor === 2 ? (
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0 animate-pulse" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0" />
)}
<span className="font-medium">{player2}</span>
</div>
</div>
)}
{/* Board */}
<div className="inline-block bg-blue-800 p-3 rounded-2xl shadow-2xl border-2 border-blue-600">
<div className="flex gap-2">
{Array.from({ length: 7 }, (_, col) => (
<div
key={col}
role={canInteract ? "button" : undefined}
aria-label={canInteract ? `Drop in column ${col}` : undefined}
className={`flex flex-col gap-2 rounded-xl p-1 transition-colors ${
canInteract
? "cursor-pointer hover:bg-blue-700/60"
: "cursor-default"
} ${hoveredCol === col && canInteract ? "bg-blue-700/60" : ""}`}
onClick={() => canInteract && onColumnClick?.(col)}
onMouseEnter={() => canInteract && setHoveredCol(col)}
onMouseLeave={() => setHoveredCol(null)}
>
{/* Drop arrow indicator */}
<div
className={`h-2 flex items-center justify-center transition-opacity ${
hoveredCol === col && canInteract
? "opacity-100"
: "opacity-0"
}`}
>
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-white/70" />
</div>
{/* Cells (top row first) */}
{Array.from({ length: 6 }, (_, rowIdx) => {
const row = 5 - rowIdx;
const cell = board[col][row];
const isLast =
lastMove?.column === col && lastMove?.row === row;
const isAnimatingDrop =
animatingMove?.column === col && animatingMove?.row === row;
const chipScale = isLast ? 1.1 : 1;
const dropDistance = `${(5 - row) * 56 + 16}px`;
return (
<div
key={row}
className={`relative w-12 h-12 rounded-full border-2 bg-slate-950 overflow-visible ${
cell === 0
? `border-slate-800 ${
hoveredCol === col && canInteract
? "border-blue-400/50"
: ""
}`
: isLast
? "border-white"
: "border-slate-900"
}`}
>
{cell !== 0 && (
<div
className={`absolute inset-0 rounded-full border-2 shadow-lg transition-transform duration-150 ${
cell === 1
? `bg-red-500 border-red-700 shadow-red-950/60`
: `bg-yellow-400 border-yellow-600 shadow-yellow-950/60`
} ${isAnimatingDrop ? "chip-drop" : ""}`}
style={
{
"--chip-drop-distance": `-${dropDistance}`,
"--chip-scale": chipScale,
transform: isAnimatingDrop
? undefined
: `scale(${chipScale})`,
} as CSSProperties
}
/>
)}
</div>
);
})}
</div>
))}
</div>
{/* Column numbers */}
<div className="flex gap-2 mt-1">
{Array.from({ length: 7 }, (_, col) => (
<div
key={col}
className="w-14 p-1 text-center text-xs text-blue-400/70 font-mono"
>
{col + 1}
</div>
))}
</div>
</div>
<style jsx>{`
.chip-drop {
animation: chip-drop ${CHIP_DROP_ANIMATION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)
forwards;
will-change: transform;
}
@keyframes chip-drop {
from {
transform: translateY(var(--chip-drop-distance))
scale(var(--chip-scale));
}
to {
transform: translateY(0) scale(var(--chip-scale));
}
}
@media (prefers-reduced-motion: reduce) {
.chip-drop {
animation: none;
}
}
`}</style>
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Confetti from "react-confetti";
import { useConnection } from "@/lib/connection";
export default function Celebration() {
const { subscribe } = useConnection();
const [winner, setWinner] = useState<string | null>(null);
const [viewport, setViewport] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateViewport = () => {
setViewport({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateViewport();
window.addEventListener("resize", updateViewport);
return () => window.removeEventListener("resize", updateViewport);
}, []);
useEffect(() => {
const unsubscribe = subscribe((msg) => {
if (msg.type !== "TOURNAMENT_WINNER") return;
setWinner(msg.username || "Unknown player");
});
return () => {
unsubscribe();
};
}, [subscribe]);
if (!winner) return null;
return (
<>
<Confetti
width={viewport.width}
height={viewport.height}
recycle={false}
numberOfPieces={600}
gravity={0.2}
className="pointer-events-none fixed! inset-0! z-90!"
/>
<div className="pointer-events-none fixed inset-0 z-100 flex items-center justify-center px-4">
<div className="pointer-events-auto w-full max-w-xl rounded-3xl border border-amber-300/40 bg-linear-to-br from-amber-300 via-yellow-200 to-orange-300 p-1px shadow-2xl shadow-amber-950/60">
<div className="rounded-[calc(1.5rem-1px)] bg-slate-950/95 px-8 py-10 text-center backdrop-blur">
<div className="text-5xl">🏆</div>
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.35em] text-amber-200/80">
Tournament Winner
</p>
<h2 className="mt-3 text-4xl font-black tracking-tight text-white sm:text-5xl">
{winner}
</h2>
<p className="mt-4 text-base text-amber-100/85">
Dominated and closed out the tournament.
</p>
<button
type="button"
onClick={() => setWinner(null)}
className="mt-7 inline-flex items-center justify-center rounded-full border border-amber-200/30 bg-amber-300/15 px-5 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/25"
>
Close
</button>
</div>
</div>
</div>
</>
);
}
-181
View File
@@ -1,181 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { SubmitEvent, useEffect, useState } from "react";
import { Settings, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import AdminSettingsPanel from "@/components/AdminSettingsPanel";
import { useConnection } from "@/lib/connection";
export default function Nav() {
const pathname = usePathname();
const router = useRouter();
const {
status,
role,
username,
becomePlayer,
disconnect,
isAdmin,
shouldRedirectToConnect,
} = useConnection();
const [showPlayerModal, setShowPlayerModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [nextUsername, setNextUsername] = useState(username);
const isConnectionPage = pathname === "/";
const disableRoleSwitch =
status === "connecting" || status === "reconnecting";
useEffect(() => {
if (isConnectionPage || (status === "disconnected" && shouldRedirectToConnect)) {
setShowSettingsModal(false);
}
}, [isConnectionPage, status, shouldRedirectToConnect]);
const handleBecomePlayer = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = nextUsername.trim();
if (!trimmed) return;
becomePlayer(trimmed);
setShowPlayerModal(false);
router.push("/play");
};
return (
<>
<nav className="bg-gray-900 border-b border-gray-800 px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center gap-4 flex-wrap">
<Link
href="/"
className="text-lg font-bold text-white flex items-center gap-2"
>
<Image
src="/icon.png"
alt="Connect4 Observer icon"
width={32}
height={32}
className="h-8 w-8 rounded-sm"
/>
<span>Connect4 Observer</span>
</Link>
<div className="ml-auto flex items-center gap-2">
{!isConnectionPage && (
<>
{role !== "player" && !isAdmin && (
<button
onClick={() => {
setNextUsername(username);
setShowPlayerModal(true);
}}
disabled={disableRoleSwitch}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white"
>
Become Player
</button>
)}
<button
onClick={disconnect}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-gray-700 hover:bg-red-600 text-white"
>
Disconnect
</button>
{role !== "player" && (
<button
type="button"
onClick={() => setShowSettingsModal(true)}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800 text-gray-200 transition-colors hover:bg-gray-700 hover:text-white"
aria-label="Open settings"
title="Settings"
>
<Settings className="h-5 w-5" />
</button>
)}
</>
)}
</div>
</div>
</nav>
{showPlayerModal && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center px-4">
<form
onSubmit={handleBecomePlayer}
className="w-full max-w-sm bg-gray-900 border border-gray-700 rounded-xl p-5 flex flex-col gap-3"
>
<h2 className="text-lg font-semibold text-white">Become Player</h2>
<p className="text-sm text-gray-400">
Enter a username to connect as a player.
</p>
<input
autoFocus
value={nextUsername}
onChange={(event) => setNextUsername(event.target.value)}
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
placeholder="Username"
/>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setShowPlayerModal(false)}
className="px-3 py-2 rounded-lg text-sm font-medium bg-gray-700 hover:bg-gray-600 text-white"
>
Cancel
</button>
<button
type="submit"
className="px-3 py-2 rounded-lg text-sm font-semibold bg-blue-600 hover:bg-blue-500 text-white"
>
Continue
</button>
</div>
</form>
</div>
)}
{showSettingsModal && (
<div className="fixed inset-0 z-50 bg-black/70 px-4 py-6">
<div className="mx-auto flex h-full max-w-4xl items-center justify-center">
<div className="flex max-h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-950 shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-white">Settings</h2>
<p className="text-sm text-gray-400">
Admin tools for tournaments, server values, and reservations.
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
isAdmin
? "border border-green-700 bg-green-950/80 text-green-300"
: "border border-gray-700 bg-gray-800 text-gray-400"
}`}
>
{isAdmin ? "Admin" : "Observer"}
</span>
<button
type="button"
onClick={() => setShowSettingsModal(false)}
className="inline-flex items-center justify-center rounded-lg bg-gray-800 p-2 text-white transition-colors hover:bg-gray-700"
aria-label="Close settings"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="overflow-y-auto p-5">
<AdminSettingsPanel />
</div>
</div>
</div>
</div>
)}
</>
);
}
-21
View File
@@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
import { useConnection } from "@/lib/connection";
const DEFAULT_TITLE = "Connect4 RPI Minds & Machines";
export default function PageTitleManager() {
const { role, username } = useConnection();
useEffect(() => {
if (role === "player" && username.trim()) {
document.title = `${username.trim()} - ${DEFAULT_TITLE}`;
return;
}
document.title = DEFAULT_TITLE;
}, [role, username]);
return null;
}
-13
View File
@@ -1,13 +0,0 @@
services:
connect4-ui:
build:
context: .
dockerfile: Dockerfile
image: joshuafhiggins/connect4-ui
container_name: connect4-ui
environment:
NODE_ENV: production
PORT: "3000"
ports:
- "3000:3000"
restart: unless-stopped
-1
View File
@@ -1 +0,0 @@
docker build . -t joshuafhiggins/connect4-ui
+516
View File
@@ -0,0 +1,516 @@
[preset.0]
name="macOS"
platform="macOS"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../../Downloads/connect4-moderator-observer.dmg"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
export/distribution_type=1
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
application/liquid_glass_icon="res://icon.icon"
application/icon="uid://dd7lvnidxr5ss"
application/icon_interpolation=0
application/bundle_identifier="com.abunchofknowitalls.connect4"
application/signature=""
application/app_category="Games"
application/short_version=""
application/version=""
application/copyright="RPI Minds & Machines"
application/copyright_localized={}
application/min_macos_version_x86_64="10.12"
application/min_macos_version_arm64="11.00"
application/export_angle=0
display/high_res=true
shader_baker/enabled=true
application/additional_plist_content=""
xcode/platform_build="14C18"
xcode/sdk_version="13.1"
xcode/sdk_build="22C55"
xcode/sdk_name="macosx13.1"
xcode/xcode_version="1420"
xcode/xcode_build="14C18"
codesign/codesign=3
codesign/installer_identity=""
codesign/apple_team_id="8S7C654DQ4"
codesign/identity="73BA692FE950ABC209210ACAA8AD412BD9C6C4A3"
codesign/entitlements/custom_file=""
codesign/entitlements/allow_jit_code_execution=false
codesign/entitlements/allow_unsigned_executable_memory=false
codesign/entitlements/allow_dyld_environment_variables=false
codesign/entitlements/disable_library_validation=true
codesign/entitlements/audio_input=false
codesign/entitlements/camera=false
codesign/entitlements/location=false
codesign/entitlements/address_book=false
codesign/entitlements/calendars=false
codesign/entitlements/photos_library=false
codesign/entitlements/apple_events=false
codesign/entitlements/debugging=false
codesign/entitlements/app_sandbox/enabled=false
codesign/entitlements/app_sandbox/network_server=false
codesign/entitlements/app_sandbox/network_client=false
codesign/entitlements/app_sandbox/device_usb=false
codesign/entitlements/app_sandbox/device_bluetooth=false
codesign/entitlements/app_sandbox/files_downloads=0
codesign/entitlements/app_sandbox/files_pictures=0
codesign/entitlements/app_sandbox/files_music=0
codesign/entitlements/app_sandbox/files_movies=0
codesign/entitlements/app_sandbox/files_user_selected=0
codesign/entitlements/app_sandbox/helper_executables=[]
codesign/entitlements/additional=""
codesign/custom_options=PackedStringArray()
notarization/notarization=2
privacy/microphone_usage_description=""
privacy/microphone_usage_description_localized={}
privacy/camera_usage_description=""
privacy/camera_usage_description_localized={}
privacy/location_usage_description=""
privacy/location_usage_description_localized={}
privacy/address_book_usage_description=""
privacy/address_book_usage_description_localized={}
privacy/calendar_usage_description=""
privacy/calendar_usage_description_localized={}
privacy/photos_library_usage_description=""
privacy/photos_library_usage_description_localized={}
privacy/desktop_folder_usage_description=""
privacy/desktop_folder_usage_description_localized={}
privacy/documents_folder_usage_description=""
privacy/documents_folder_usage_description_localized={}
privacy/downloads_folder_usage_description=""
privacy/downloads_folder_usage_description_localized={}
privacy/network_volumes_usage_description=""
privacy/network_volumes_usage_description_localized={}
privacy/removable_volumes_usage_description=""
privacy/removable_volumes_usage_description_localized={}
privacy/tracking_enabled=false
privacy/tracking_domains=PackedStringArray()
privacy/collected_data/name/collected=false
privacy/collected_data/name/linked_to_user=false
privacy/collected_data/name/used_for_tracking=false
privacy/collected_data/name/collection_purposes=0
privacy/collected_data/email_address/collected=false
privacy/collected_data/email_address/linked_to_user=false
privacy/collected_data/email_address/used_for_tracking=false
privacy/collected_data/email_address/collection_purposes=0
privacy/collected_data/phone_number/collected=false
privacy/collected_data/phone_number/linked_to_user=false
privacy/collected_data/phone_number/used_for_tracking=false
privacy/collected_data/phone_number/collection_purposes=0
privacy/collected_data/physical_address/collected=false
privacy/collected_data/physical_address/linked_to_user=false
privacy/collected_data/physical_address/used_for_tracking=false
privacy/collected_data/physical_address/collection_purposes=0
privacy/collected_data/other_contact_info/collected=false
privacy/collected_data/other_contact_info/linked_to_user=false
privacy/collected_data/other_contact_info/used_for_tracking=false
privacy/collected_data/other_contact_info/collection_purposes=0
privacy/collected_data/health/collected=false
privacy/collected_data/health/linked_to_user=false
privacy/collected_data/health/used_for_tracking=false
privacy/collected_data/health/collection_purposes=0
privacy/collected_data/fitness/collected=false
privacy/collected_data/fitness/linked_to_user=false
privacy/collected_data/fitness/used_for_tracking=false
privacy/collected_data/fitness/collection_purposes=0
privacy/collected_data/payment_info/collected=false
privacy/collected_data/payment_info/linked_to_user=false
privacy/collected_data/payment_info/used_for_tracking=false
privacy/collected_data/payment_info/collection_purposes=0
privacy/collected_data/credit_info/collected=false
privacy/collected_data/credit_info/linked_to_user=false
privacy/collected_data/credit_info/used_for_tracking=false
privacy/collected_data/credit_info/collection_purposes=0
privacy/collected_data/other_financial_info/collected=false
privacy/collected_data/other_financial_info/linked_to_user=false
privacy/collected_data/other_financial_info/used_for_tracking=false
privacy/collected_data/other_financial_info/collection_purposes=0
privacy/collected_data/precise_location/collected=false
privacy/collected_data/precise_location/linked_to_user=false
privacy/collected_data/precise_location/used_for_tracking=false
privacy/collected_data/precise_location/collection_purposes=0
privacy/collected_data/coarse_location/collected=false
privacy/collected_data/coarse_location/linked_to_user=false
privacy/collected_data/coarse_location/used_for_tracking=false
privacy/collected_data/coarse_location/collection_purposes=0
privacy/collected_data/sensitive_info/collected=false
privacy/collected_data/sensitive_info/linked_to_user=false
privacy/collected_data/sensitive_info/used_for_tracking=false
privacy/collected_data/sensitive_info/collection_purposes=0
privacy/collected_data/contacts/collected=false
privacy/collected_data/contacts/linked_to_user=false
privacy/collected_data/contacts/used_for_tracking=false
privacy/collected_data/contacts/collection_purposes=0
privacy/collected_data/emails_or_text_messages/collected=false
privacy/collected_data/emails_or_text_messages/linked_to_user=false
privacy/collected_data/emails_or_text_messages/used_for_tracking=false
privacy/collected_data/emails_or_text_messages/collection_purposes=0
privacy/collected_data/photos_or_videos/collected=false
privacy/collected_data/photos_or_videos/linked_to_user=false
privacy/collected_data/photos_or_videos/used_for_tracking=false
privacy/collected_data/photos_or_videos/collection_purposes=0
privacy/collected_data/audio_data/collected=false
privacy/collected_data/audio_data/linked_to_user=false
privacy/collected_data/audio_data/used_for_tracking=false
privacy/collected_data/audio_data/collection_purposes=0
privacy/collected_data/gameplay_content/collected=false
privacy/collected_data/gameplay_content/linked_to_user=false
privacy/collected_data/gameplay_content/used_for_tracking=false
privacy/collected_data/gameplay_content/collection_purposes=0
privacy/collected_data/customer_support/collected=false
privacy/collected_data/customer_support/linked_to_user=false
privacy/collected_data/customer_support/used_for_tracking=false
privacy/collected_data/customer_support/collection_purposes=0
privacy/collected_data/other_user_content/collected=false
privacy/collected_data/other_user_content/linked_to_user=false
privacy/collected_data/other_user_content/used_for_tracking=false
privacy/collected_data/other_user_content/collection_purposes=0
privacy/collected_data/browsing_history/collected=false
privacy/collected_data/browsing_history/linked_to_user=false
privacy/collected_data/browsing_history/used_for_tracking=false
privacy/collected_data/browsing_history/collection_purposes=0
privacy/collected_data/search_history/collected=false
privacy/collected_data/search_history/linked_to_user=false
privacy/collected_data/search_history/used_for_tracking=false
privacy/collected_data/search_history/collection_purposes=0
privacy/collected_data/user_id/collected=false
privacy/collected_data/user_id/linked_to_user=false
privacy/collected_data/user_id/used_for_tracking=false
privacy/collected_data/user_id/collection_purposes=0
privacy/collected_data/device_id/collected=false
privacy/collected_data/device_id/linked_to_user=false
privacy/collected_data/device_id/used_for_tracking=false
privacy/collected_data/device_id/collection_purposes=0
privacy/collected_data/purchase_history/collected=false
privacy/collected_data/purchase_history/linked_to_user=false
privacy/collected_data/purchase_history/used_for_tracking=false
privacy/collected_data/purchase_history/collection_purposes=0
privacy/collected_data/product_interaction/collected=false
privacy/collected_data/product_interaction/linked_to_user=false
privacy/collected_data/product_interaction/used_for_tracking=false
privacy/collected_data/product_interaction/collection_purposes=0
privacy/collected_data/advertising_data/collected=false
privacy/collected_data/advertising_data/linked_to_user=false
privacy/collected_data/advertising_data/used_for_tracking=false
privacy/collected_data/advertising_data/collection_purposes=0
privacy/collected_data/other_usage_data/collected=false
privacy/collected_data/other_usage_data/linked_to_user=false
privacy/collected_data/other_usage_data/used_for_tracking=false
privacy/collected_data/other_usage_data/collection_purposes=0
privacy/collected_data/crash_data/collected=false
privacy/collected_data/crash_data/linked_to_user=false
privacy/collected_data/crash_data/used_for_tracking=false
privacy/collected_data/crash_data/collection_purposes=0
privacy/collected_data/performance_data/collected=false
privacy/collected_data/performance_data/linked_to_user=false
privacy/collected_data/performance_data/used_for_tracking=false
privacy/collected_data/performance_data/collection_purposes=0
privacy/collected_data/other_diagnostic_data/collected=false
privacy/collected_data/other_diagnostic_data/linked_to_user=false
privacy/collected_data/other_diagnostic_data/used_for_tracking=false
privacy/collected_data/other_diagnostic_data/collection_purposes=0
privacy/collected_data/environment_scanning/collected=false
privacy/collected_data/environment_scanning/linked_to_user=false
privacy/collected_data/environment_scanning/used_for_tracking=false
privacy/collected_data/environment_scanning/collection_purposes=0
privacy/collected_data/hands/collected=false
privacy/collected_data/hands/linked_to_user=false
privacy/collected_data/hands/used_for_tracking=false
privacy/collected_data/hands/collection_purposes=0
privacy/collected_data/head/collected=false
privacy/collected_data/head/linked_to_user=false
privacy/collected_data/head/used_for_tracking=false
privacy/collected_data/head/collection_purposes=0
privacy/collected_data/other_data_types/collected=false
privacy/collected_data/other_data_types/linked_to_user=false
privacy/collected_data/other_data_types/used_for_tracking=false
privacy/collected_data/other_data_types/collection_purposes=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
open \"{temp_dir}/{exe_name}.app\" --args {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
kill $(pgrep -x -f \"{temp_dir}/{exe_name}.app/Contents/MacOS/{exe_name} {cmd_args}\")
rm -rf \"{temp_dir}\""
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=false
dotnet/embed_build_outputs=false
privacy/collected_data/search_hhistory/collected=false
privacy/collected_data/search_hhistory/linked_to_user=false
privacy/collected_data/search_hhistory/used_for_tracking=false
privacy/collected_data/search_hhistory/collection_purposes=0
[preset.1]
name="Windows (x86_64)"
platform="Windows Desktop"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../../Downloads/connect4-moderator-observer (win-x86_64).exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=true
binary_format/architecture="x86_64"
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon="uid://dd7lvnidxr5ss"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright="RPI Minds & Machines"
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
Remove-Item -Recurse -Force '{temp_dir}'"
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=false
dotnet/embed_build_outputs=true
[preset.2]
name="Windows (arm64)"
platform="Windows Desktop"
runnable=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../../Downloads/connect4-moderator-observer (win-arm64).exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.2.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="arm64"
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon="uid://dd7lvnidxr5ss"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
Remove-Item -Recurse -Force '{temp_dir}'"
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=false
dotnet/embed_build_outputs=true
[preset.3]
name="Linux (x86_64)"
platform="Linux"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../../Downloads/connect4-moderator-observer (linux-x86_64).out"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
export DISPLAY=:0
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
\"{temp_dir}/{exe_name}\" {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\")
rm -rf \"{temp_dir}\""
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=false
dotnet/embed_build_outputs=true
[preset.4]
name="Linux (arm64)"
platform="Linux"
runnable=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../../Downloads/connect4-moderator-observer (linux-arm64).out"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.4.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="arm64"
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash
export DISPLAY=:0
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
\"{temp_dir}/{exe_name}\" {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\")
rm -rf \"{temp_dir}\""
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=false
dotnet/embed_build_outputs=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dbn4ae2a7ao0t"
path="res://.godot/imported/icon.png-d1b269abce4c61d818c5589654ffbcc2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.icon/Assets/icon.png"
dest_files=["res://.godot/imported/icon.png-d1b269abce4c61d818c5589654ffbcc2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+37
View File
@@ -0,0 +1,37 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "icon.png",
"name" : "icon",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dd7lvnidxr5ss"
path="res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.png"
dest_files=["res://.godot/imported/icon.png-487276ed1e3a0c39cad0279d744ee560.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
-430
View File
@@ -1,430 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
DEFAULT_WS_URL,
ParsedMessage,
RECONNECT_INTERVAL_MS,
RECONNECT_TIMEOUT_MS,
cmd,
parseMessage,
} from "@/lib/protocol";
export type ConnectionRole = "observer" | "player";
export type ConnectionStatus =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
type MessageListener = (message: ParsedMessage, raw: string) => void;
interface ConnectOptions {
role: ConnectionRole;
wsUrl: string;
username?: string;
}
interface ConnectionContextValue {
role: ConnectionRole | null;
wsUrl: string;
username: string;
status: ConnectionStatus;
isInMatch: boolean;
shouldGoFirst: boolean;
isAdmin: boolean;
reconnectAttempts: number;
shouldRedirectToConnect: boolean;
becomePlayer: (username: string) => void;
authenticateAdmin: (password: string) => boolean;
connect: (options: ConnectOptions) => void;
disconnect: () => void;
send: (message: string) => boolean;
subscribe: (listener: MessageListener) => () => void;
clearRedirectFlag: () => void;
}
const ConnectionContext = createContext<ConnectionContextValue | null>(null);
interface SessionState {
role: ConnectionRole;
wsUrl: string;
username: string;
}
export function ConnectionProvider({
children,
}: {
children: React.ReactNode;
}) {
const [role, setRole] = useState<ConnectionRole | null>(null);
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
const [username, setUsername] = useState("");
const [status, setStatus] = useState<ConnectionStatus>("idle");
const [isInMatch, setIsInMatch] = useState(false);
const [shouldGoFirst, setShouldGoFirst] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const listenersRef = useRef<Set<MessageListener>>(new Set());
const manualCloseRef = useRef(false);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDeadlineRef = useRef<number | null>(null);
const reconnectActiveRef = useRef(false);
const isInMatchRef = useRef(false);
const shouldGoFirstRef = useRef(false);
const sessionRef = useRef<SessionState | null>(null);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const clearReconnectState = useCallback(() => {
reconnectActiveRef.current = false;
reconnectDeadlineRef.current = null;
clearReconnectTimer();
setReconnectAttempts(0);
}, [clearReconnectTimer]);
const emitMessage = useCallback((message: ParsedMessage, raw: string) => {
listenersRef.current.forEach((listener) => listener(message, raw));
}, []);
const safeCloseSocket = useCallback(() => {
const current = wsRef.current;
if (!current) return;
current.onopen = null;
current.onmessage = null;
current.onclose = null;
current.onerror = null;
try {
current.close();
} catch {
// no-op
}
wsRef.current = null;
}, []);
const handleDisconnect = useCallback(() => {
const currentRole = sessionRef.current?.role;
if (currentRole === "observer") {
clearReconnectState();
setStatus("disconnected");
setShouldRedirectToConnect(true);
return;
}
if (currentRole === "player" && isInMatchRef.current) {
if (reconnectActiveRef.current) {
setStatus("reconnecting");
return;
}
reconnectActiveRef.current = true;
reconnectDeadlineRef.current = Date.now() + RECONNECT_TIMEOUT_MS;
setStatus("reconnecting");
setReconnectAttempts(0);
return;
}
clearReconnectState();
setStatus("disconnected");
setShouldRedirectToConnect(true);
}, [clearReconnectState]);
const attachSocket = useCallback(
(socket: WebSocket, reconnecting: boolean) => {
socket.onopen = () => {
const session = sessionRef.current;
if (!session) return;
if (session.role === "observer") {
socket.send(cmd.observe());
return;
}
if (reconnecting) {
socket.send(cmd.reconnect(session.username));
} else {
socket.send(cmd.connect(session.username));
}
};
socket.onmessage = (event) => {
const raw = event.data as string;
if (process.env.NODE_ENV === "development") {
console.log("Recieved: " + raw);
}
const parsed = parseMessage(raw);
if (parsed.type === "OBSERVE_ACK") {
setRole("observer");
setShouldRedirectToConnect(false);
setStatus("connected");
setIsAdmin(false);
}
if (parsed.type === "CONNECT_ACK") {
setRole("player");
setIsAdmin(false);
}
if (parsed.type === "RECONNECT_ACK") {
clearReconnectState();
setShouldRedirectToConnect(false);
setStatus("connected");
setIsAdmin(false);
}
if (parsed.type === "DISCONNECT_ACK") {
setRole("observer");
setUsername("");
setIsAdmin(false);
isInMatchRef.current = false;
setIsInMatch(false);
}
if (parsed.type === "ADMIN_AUTH_ACK") {
setIsAdmin(true);
}
if (parsed.type === "GAME_START") {
isInMatchRef.current = true;
setIsInMatch(true);
shouldGoFirstRef.current = parsed.goesFirst;
setShouldGoFirst(parsed.goesFirst);
}
if (
parsed.type === "GAME_WINS" ||
parsed.type === "GAME_LOSS" ||
parsed.type === "GAME_DRAW" ||
parsed.type === "GAME_TERMINATED"
) {
isInMatchRef.current = false;
setIsInMatch(false);
}
if (
parsed.type === "ERROR" &&
reconnecting &&
parsed.message.startsWith("ERROR:INVALID:RECONNECT")
) {
safeCloseSocket();
}
emitMessage(parsed, raw);
};
socket.onclose = () => {
wsRef.current = null;
if (manualCloseRef.current) {
manualCloseRef.current = false;
return;
}
handleDisconnect();
};
socket.onerror = () => {
// Allow close event to drive state transitions.
};
},
[clearReconnectState, emitMessage, handleDisconnect, safeCloseSocket],
);
const openSocket = useCallback(
(reconnecting: boolean) => {
const session = sessionRef.current;
if (!session) return;
safeCloseSocket();
manualCloseRef.current = false;
const socket = new WebSocket(session.wsUrl);
wsRef.current = socket;
attachSocket(socket, reconnecting);
},
[attachSocket, safeCloseSocket],
);
useEffect(() => {
if (!reconnectActiveRef.current) return;
const runReconnectAttempt = () => {
if (!reconnectActiveRef.current) return;
const deadline = reconnectDeadlineRef.current;
if (!deadline || Date.now() >= deadline) {
reconnectActiveRef.current = false;
reconnectDeadlineRef.current = null;
setStatus("disconnected");
setShouldRedirectToConnect(true);
return;
}
setReconnectAttempts((prev) => prev + 1);
openSocket(true);
clearReconnectTimer();
reconnectTimerRef.current = setTimeout(
runReconnectAttempt,
RECONNECT_INTERVAL_MS,
);
};
runReconnectAttempt();
return () => clearReconnectTimer();
}, [clearReconnectTimer, openSocket, status]);
const connect = useCallback(
({ role, wsUrl, username }: ConnectOptions) => {
const resolvedUsername = (username ?? "").trim();
sessionRef.current = { role, wsUrl, username: resolvedUsername };
setRole(role);
setWsUrl(wsUrl);
setUsername(resolvedUsername);
setShouldRedirectToConnect(false);
clearReconnectState();
isInMatchRef.current = false;
setIsInMatch(false);
setIsAdmin(false);
setStatus("connecting");
openSocket(false);
},
[clearReconnectState, openSocket],
);
const send = useCallback((message: string) => {
if (wsRef.current?.readyState !== WebSocket.OPEN) return false;
if (process.env.NODE_ENV === "development") {
console.log("Sending: " + message);
}
wsRef.current.send(message);
return true;
}, []);
const becomePlayer = useCallback(
(username: string) => {
const resolvedUsername = (username ?? "").trim();
setRole("player");
setUsername(resolvedUsername);
isInMatchRef.current = false;
setIsInMatch(false);
setIsAdmin(false);
send(cmd.connect(resolvedUsername));
},
[send],
);
const authenticateAdmin = useCallback(
(password: string) => {
const trimmed = password.trim();
if (!trimmed) return false;
return send(cmd.adminAuth(trimmed));
},
[send],
);
const disconnect = useCallback(() => {
clearReconnectState();
manualCloseRef.current = true;
safeCloseSocket();
sessionRef.current = null;
setRole(null);
setStatus("idle");
setUsername("");
setIsInMatch(false);
setIsAdmin(false);
isInMatchRef.current = false;
setShouldRedirectToConnect(false);
}, [clearReconnectState, safeCloseSocket]);
const subscribe = useCallback((listener: MessageListener) => {
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
}, []);
const clearRedirectFlag = useCallback(() => {
setShouldRedirectToConnect(false);
}, []);
useEffect(() => {
return () => {
clearReconnectState();
manualCloseRef.current = true;
safeCloseSocket();
};
}, [clearReconnectState, safeCloseSocket]);
const value = useMemo<ConnectionContextValue>(
() => ({
role,
wsUrl,
username,
status,
isInMatch,
shouldGoFirst,
isAdmin,
reconnectAttempts,
shouldRedirectToConnect,
becomePlayer,
authenticateAdmin,
connect,
disconnect,
send,
subscribe,
clearRedirectFlag,
}),
[
role,
wsUrl,
username,
status,
isInMatch,
shouldGoFirst,
isAdmin,
reconnectAttempts,
shouldRedirectToConnect,
becomePlayer,
authenticateAdmin,
connect,
disconnect,
send,
subscribe,
clearRedirectFlag,
],
);
return (
<ConnectionContext.Provider value={value}>
{children}
</ConnectionContext.Provider>
);
}
export function useConnection() {
const context = useContext(ConnectionContext);
if (!context) {
throw new Error("useConnection must be used within a ConnectionProvider");
}
return context;
}
-374
View File
@@ -1,374 +0,0 @@
// ─── Types ───────────────────────────────────────────────────────────────────
export interface GameEntry {
id: number;
player1: string;
player2: string;
}
export interface PlayerEntry {
username: string;
ready: boolean;
inMatch: boolean;
}
export interface ScoreEntry {
player: string;
score: number;
}
export interface MoveEntry {
username: string;
column: number;
}
export interface ReservationEntry {
player1: string;
player2: string;
}
export const DEFAULT_WS_URL =
process.env.NODE_ENV === "development"
? "ws://localhost:8080"
: "wss://connect4.abunchofknowitalls.com/ws";
export const RECONNECT_INTERVAL_MS = 5000;
export const RECONNECT_TIMEOUT_MS = 60000;
// ─── Parsed message union ────────────────────────────────────────────────────
export type ParsedMessage =
| { type: "CONNECT_ACK" }
| { type: "CONNECT_EVENT"; username: string }
| { type: "RECONNECT_ACK" }
| { type: "DISCONNECT_ACK" }
| { type: "DISCONNECT_EVENT"; username: string }
| { type: "OBSERVE_ACK"; enabled: boolean }
| { type: "READY_ACK" }
| { type: "READY_EVENT"; username: string; ready: boolean }
| { type: "GAME_START"; goesFirst: boolean }
| {
type: "GAME_MATCH_START";
matchId: number;
player1: string;
player2: string;
}
| { type: "GAME_WINS" }
| { type: "GAME_LOSS" }
| { type: "GAME_DRAW"; matchId?: number }
| { type: "GAME_TERMINATED"; matchId?: number }
| { type: "OPPONENT_MOVE"; column: number }
| { type: "GAME_LIST"; games: GameEntry[] }
| {
type: "GAME_WATCH_ACK";
matchId: number;
player1: string;
player2: string;
moves: MoveEntry[];
}
| { type: "GAME_MOVE"; matchId?: number; username: string; column: number }
| { type: "GAME_WIN"; matchId?: number; winner: string }
| { type: "PLAYER_LIST"; players: PlayerEntry[] }
| { type: "TOURNAMENT_START"; tournamentType: string }
| { type: "TOURNAMENT_CANCEL" }
| { type: "TOURNAMENT_SCORES"; scores: ScoreEntry[] }
| { type: "TOURNAMENT_WINNER"; username: string }
| { type: "TOURNAMENT_END" }
| { type: "ADMIN_AUTH_ACK" }
| { type: "RESERVATION_ADD"; player1: string; player2: string }
| { type: "RESERVATION_DELETE"; player1: string; player2: string }
| { type: "RESERVATION_LIST"; reservations: ReservationEntry[] }
| { type: "GET_DATA"; key: string; value: string }
| { type: "SET_DATA_ACK"; key: string }
| { type: "ERROR"; message: string }
| { type: "UNKNOWN"; raw: string };
// ─── Parser ──────────────────────────────────────────────────────────────────
export function parseMessage(raw: string): ParsedMessage {
const parts = raw.split(":");
switch (parts[0]) {
case "CONNECT":
if (parts[1] === "ACK") return { type: "CONNECT_ACK" };
return { type: "CONNECT_EVENT", username: parts[1] ?? "" };
break;
case "RECONNECT":
if (parts[1] === "ACK") return { type: "RECONNECT_ACK" };
break;
case "DISCONNECT":
if (parts[1] === "ACK") return { type: "DISCONNECT_ACK" };
return { type: "DISCONNECT_EVENT", username: parts[1] ?? "" };
break;
case "OBSERVE":
if (parts[1] === "ACK") {
return { type: "OBSERVE_ACK", enabled: parts[2] === "1" };
}
break;
case "READY":
if (parts[1] === "ACK") return { type: "READY_ACK" };
if (parts.length >= 3) {
return {
type: "READY_EVENT",
username: parts[1],
ready: parts[2] === "true",
};
}
break;
case "GAME": {
const scopedMatchId = parseInt(parts[1], 10);
if (!Number.isNaN(scopedMatchId)) {
switch (parts[2]) {
case "MOVE":
return {
type: "GAME_MOVE",
matchId: scopedMatchId,
username: parts[3],
column: parseInt(parts[4], 10),
};
case "WIN":
return {
type: "GAME_WIN",
matchId: scopedMatchId,
winner: parts[3],
};
case "DRAW":
return { type: "GAME_DRAW", matchId: scopedMatchId };
case "TERMINATED":
return { type: "GAME_TERMINATED", matchId: scopedMatchId };
}
}
switch (parts[1]) {
case "START":
if (parts[2]?.includes(",")) {
const [matchId, player1, player2] = parts[2].split(",");
return {
type: "GAME_MATCH_START",
matchId: parseInt(matchId, 10),
player1,
player2,
};
}
return { type: "GAME_START", goesFirst: parts[2] === "1" };
case "WINS":
return { type: "GAME_WINS" };
case "LOSS":
return { type: "GAME_LOSS" };
case "DRAW":
return { type: "GAME_DRAW" };
case "TERMINATED":
return { type: "GAME_TERMINATED" };
case "LIST": {
const data = parts[2] ?? "";
if (!data) return { type: "GAME_LIST", games: [] };
const games: GameEntry[] = data.split("|").map((g) => {
const [id, player1, player2] = g.split(",");
return { id: parseInt(id), player1, player2 };
});
return { type: "GAME_LIST", games };
}
case "WATCH": {
if (parts[2] === "ACK") {
// GAME:WATCH:ACK:<id>,<p1>,<p2>|<username>,<col>|...
const data = parts.slice(3).join(":");
const segments = data.split("|");
const [idStr, player1, player2] = segments[0].split(",");
const moves: MoveEntry[] = segments
.slice(1)
.filter(Boolean)
.map((m) => {
const lastComma = m.lastIndexOf(",");
return {
username: m.substring(0, lastComma),
column: parseInt(m.substring(lastComma + 1)),
};
});
return {
type: "GAME_WATCH_ACK",
matchId: parseInt(idStr),
player1,
player2,
moves,
};
}
break;
}
}
break;
}
case "OPPONENT":
return {
type: "OPPONENT_MOVE",
column: parseInt(parts[parts.length - 1], 10),
};
case "PLAYER": {
if (parts[1] === "LIST") {
const data = parts[2] ?? "";
if (!data) return { type: "PLAYER_LIST", players: [] };
const players: PlayerEntry[] = data.split("|").map((p) => {
const [username, ready, inMatch] = p.split(",");
return {
username,
ready: ready === "true",
inMatch: inMatch === "true",
};
});
return { type: "PLAYER_LIST", players };
}
break;
}
case "TOURNAMENT": {
switch (parts[1]) {
case "START":
return { type: "TOURNAMENT_START", tournamentType: parts[2] };
case "CANCEL":
return { type: "TOURNAMENT_CANCEL" };
case "SCORES": {
const data = parts[2] ?? "";
if (!data) return { type: "TOURNAMENT_SCORES", scores: [] };
const scores: ScoreEntry[] = data.split("|").map((s) => {
const lastComma = s.lastIndexOf(",");
return {
player: s.substring(0, lastComma),
score: parseInt(s.substring(lastComma + 1)),
};
});
return { type: "TOURNAMENT_SCORES", scores };
}
case "WINNER":
return { type: "TOURNAMENT_WINNER", username: parts[2] ?? "" };
case "END":
return { type: "TOURNAMENT_END" };
}
break;
}
case "ADMIN":
if (parts[1] === "AUTH" && parts[2] === "ACK")
return { type: "ADMIN_AUTH_ACK" };
break;
case "GET":
return {
type: "GET_DATA",
key: parts[1],
value: parts.slice(2).join(":"),
};
case "SET":
if (parts[2] === "ACK") return { type: "SET_DATA_ACK", key: parts[1] };
break;
case "RESERVATION": {
const payload = parts[2] ?? "";
if (parts[1] === "ADD" || parts[1] === "DELETE") {
const [player1, player2] = payload.split(",");
if (player1 && player2) {
return {
type: parts[1] === "ADD" ? "RESERVATION_ADD" : "RESERVATION_DELETE",
player1,
player2,
};
}
}
if (parts[1] === "LIST") {
const reservations =
payload.length === 0
? []
: payload
.split("|")
.map((entry) => {
const [player1, player2] = entry.split(",");
return player1 && player2 ? { player1, player2 } : null;
})
.filter((entry): entry is ReservationEntry => entry !== null);
return { type: "RESERVATION_LIST", reservations };
}
break;
}
case "ERROR":
return { type: "ERROR", message: raw };
}
return { type: "UNKNOWN", raw };
}
// ─── Command builders ────────────────────────────────────────────────────────
export const cmd = {
connect: (username: string) => `CONNECT:${username}`,
reconnect: (username: string) => `RECONNECT:${username}`,
disconnect: () => "DISCONNECT",
observe: () => "OBSERVE",
ready: () => "READY",
play: (column: number) => `PLAY:${column}`,
playerList: () => "PLAYER:LIST",
gameList: () => "GAME:LIST",
gameWatch: (matchId: number) => `GAME:WATCH:${matchId}`,
gameTerminate: (matchId: number) => `GAME:TERMINATE:${matchId}`,
gameAward: (matchId: number, winner: string) =>
`GAME:AWARD:${matchId}:${winner}`,
adminAuth: (password: string) => `ADMIN:AUTH:${password}`,
adminKick: (username: string) => `ADMIN:KICK:${username}`,
tournamentStart: (type = "RoundRobin") => `TOURNAMENT:START:${type}`,
tournamentCancel: () => "TOURNAMENT:CANCEL",
getData: (key: string) => `GET:${key}`,
setData: (key: string, value: string) => `SET:${key}:${value}`,
reservationAdd: (p1: string, p2: string) => `RESERVATION:ADD:${p1},${p2}`,
reservationDelete: (p1: string, p2: string) =>
`RESERVATION:DELETE:${p1},${p2}`,
reservationGet: () => "RESERVATION:GET",
};
// ─── Board helpers ────────────────────────────────────────────────────────────
// 0 = empty, 1 = red (player1 / goes first), 2 = yellow (player2)
export type CellColor = 0 | 1 | 2;
export type BoardState = CellColor[][]; // board[col][row], 7 cols × 6 rows
export function createEmptyBoard(): BoardState {
return Array.from({ length: 7 }, () => Array(6).fill(0)) as BoardState;
}
/** Place a token and return the new board plus the row it landed in (-1 if column full). */
export function placeToken(
board: BoardState,
color: 1 | 2,
column: number,
): { board: BoardState; row: number } {
const newBoard = board.map((col) => [...col]) as BoardState;
let placedRow = -1;
for (let row = 0; row < 6; row++) {
if (newBoard[column][row] === 0) {
newBoard[column][row] = color;
placedRow = row;
break;
}
}
return { board: newBoard, row: placedRow };
}
/** Replay a move list onto an empty board. */
export function replayMoves(
moves: MoveEntry[],
player1: string,
): { board: BoardState; lastMove: { column: number; row: number } | null } {
let board = createEmptyBoard();
let lastMove: { column: number; row: number } | null = null;
for (const move of moves) {
const color: 1 | 2 = move.username === player1 ? 1 : 2;
const result = placeToken(board, color, move.column);
board = result.board;
lastMove = { column: move.column, row: result.row };
}
return { board, lastMove };
}
-4
View File
@@ -1,4 +0,0 @@
export const CHIP_DROP_SOUND_PATHS = Array.from(
{ length: 7 },
(_, index) => `/sfx/chip_collide_${index + 1}.ogg`,
);
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
-6657
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
{
"name": "connect4-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"lucide-react": "^1.8.0",
"next": "16.2.4",
"react": "19.2.5",
"react-confetti": "^6.4.0",
"react-dom": "19.2.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"postcss": "^8",
"prettier": "^3.8.1",
"tailwindcss": "^4",
"typescript": "^5"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};
+47
View File
@@ -0,0 +1,47 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[animation]
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Connect4 Observer"
config/version="1.0.1"
run/main_scene="uid://dcx5nvs0pa7me"
config/features=PackedStringArray("4.6", "C#", "Forward Plus")
boot_splash/image="uid://dd7lvnidxr5ss"
config/icon="uid://dd7lvnidxr5ss"
[autoload]
Connection="*res://scripts/Connection.cs"
BackgroundMusic="*res://scripts/background_music.gd"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
window/vsync/vsync_mode=2
[dotnet]
project/assembly_name="connect4-moderator-observer"
[rendering]
textures/canvas_textures/default_texture_filter=0
textures/vram_compression/import_s3tc_bptc=true
textures/vram_compression/import_etc2_astc=true
anti_aliasing/quality/msaa_2d=2
anti_aliasing/quality/msaa_3d=2
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

+193
View File
@@ -0,0 +1,193 @@
[gd_scene load_steps=17 format=3 uid="uid://m542qwlp7hl7"]
[ext_resource type="Script" uid="uid://dg5jt0o0r0v3r" path="res://scripts/BoardScreen.cs" id="1_b3w8x"]
[ext_resource type="AudioStream" uid="uid://crxjuk1vyq331" path="res://assets/sfx/game_end.ogg" id="2_kseed"]
[ext_resource type="Texture2D" uid="uid://dlx02qat7j6lf" path="res://assets/sprites/AssetTileset.png" id="3_1tlhv"]
[ext_resource type="Theme" uid="uid://bbgxacei1vwba" path="res://assets/theme.tres" id="3_3louw"]
[ext_resource type="FontFile" uid="uid://c3jmev24lo6ci" path="res://assets/fonts/PixelOperator8.ttf" id="3_rjcmr"]
[ext_resource type="Texture2D" uid="uid://ckmfi0cjgxgyk" path="res://assets/sprites/RedChip.png" id="4_1hrcj"]
[ext_resource type="Texture2D" uid="uid://qy30emdgrk7o" path="res://assets/sprites/YellowChip.png" id="5_i2o8i"]
[ext_resource type="Script" uid="uid://cwfg17tdbk44b" path="res://scripts/thinking.gd" id="5_wjs8a"]
[ext_resource type="Texture2D" uid="uid://8un28mol7qow" path="res://assets/sprites/BoardTileMap.png" id="6_i2o8i"]
[ext_resource type="PackedScene" uid="uid://pdean68jjg80" path="res://scenes/button_small.tscn" id="7_glh1q"]
[ext_resource type="Script" uid="uid://b3q4gq63qmx23" path="res://scripts/BackButton.cs" id="8_u1oi2"]
[sub_resource type="WorldBoundaryShape2D" id="WorldBoundaryShape2D_b3w8x"]
[sub_resource type="SegmentShape2D" id="SegmentShape2D_i2o8i"]
a = Vector2(0, -276)
b = Vector2(0, 207)
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_i2o8i"]
texture = ExtResource("6_i2o8i")
texture_region_size = Vector2i(26, 26)
0:0/0 = 0
1:0/0 = 0
2:0/0 = 0
3:0/0 = 0
[sub_resource type="TileSet" id="TileSet_glh1q"]
tile_size = Vector2i(26, 26)
sources/0 = SubResource("TileSetAtlasSource_i2o8i")
[sub_resource type="AtlasTexture" id="AtlasTexture_glh1q"]
atlas = ExtResource("3_1tlhv")
region = Rect2(112, 32, 16, 16)
[node name="BoardScreen" type="Node2D"]
script = ExtResource("1_b3w8x")
endingSfx = ExtResource("2_kseed")
theme = ExtResource("3_3louw")
[node name="Floor Collider" type="StaticBody2D" parent="."]
position = Vector2(0, 200)
[node name="CollisionShape2D" type="CollisionShape2D" parent="Floor Collider"]
shape = SubResource("WorldBoundaryShape2D_b3w8x")
[node name="Column Colliders" type="StaticBody2D" parent="."]
[node name="Col0" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(-272, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col1" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(-195, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col2" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(-117, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col3" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(-39, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col4" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(39, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col5" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(117, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col6" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(195, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Col7" type="CollisionShape2D" parent="Column Colliders"]
position = Vector2(274, 0)
shape = SubResource("SegmentShape2D_i2o8i")
[node name="Player1Card" type="Node2D" parent="."]
position = Vector2(0, 8)
[node name="Left" type="Sprite2D" parent="Player1Card"]
position = Vector2(-556, -300)
scale = Vector2(2, 2)
texture = ExtResource("3_1tlhv")
region_enabled = true
region_rect = Rect2(60, 4, 20, 24)
[node name="Center" type="Sprite2D" parent="Player1Card"]
position = Vector2(-482, -300)
scale = Vector2(6.75, 2)
texture = ExtResource("3_1tlhv")
region_enabled = true
region_rect = Rect2(80, 4, 16, 24)
[node name="Right" type="Sprite2D" parent="Player1Card"]
position = Vector2(-420, -300)
scale = Vector2(2, 2)
texture = ExtResource("3_1tlhv")
region_enabled = true
region_rect = Rect2(96, 4, 8, 24)
[node name="RedChip" type="Sprite2D" parent="Player1Card/Right"]
position = Vector2(-7, 6)
scale = Vector2(0.25, 0.25)
texture = ExtResource("4_1hrcj")
[node name="Name" type="Label" parent="Player1Card"]
offset_left = -530.0
offset_top = -312.0
offset_right = -428.0
offset_bottom = -296.0
theme_override_fonts/font = ExtResource("3_rjcmr")
text = "Player 1"
[node name="Status" type="Label" parent="Player1Card"]
offset_left = -529.0
offset_top = -292.0
offset_right = -427.0
offset_bottom = -284.0
theme_override_fonts/font = ExtResource("3_rjcmr")
theme_override_font_sizes/font_size = 8
text = "THINKING"
script = ExtResource("5_wjs8a")
[node name="Player2Card" type="Node2D" parent="."]
position = Vector2(989, -64)
[node name="Left" type="Sprite2D" parent="Player2Card"]
position = Vector2(-556, -228)
scale = Vector2(2, 2)
texture = ExtResource("3_1tlhv")
region_enabled = true
region_rect = Rect2(60, 4, 20, 24)
[node name="Center" type="Sprite2D" parent="Player2Card"]
position = Vector2(-482.5, -228)
scale = Vector2(6.6875, 2)
texture = ExtResource("3_1tlhv")
region_enabled = true
region_rect = Rect2(80, 4, 16, 24)
[node name="Right" type="Sprite2D" parent="Player2Card"]
position = Vector2(-421, -228)
scale = Vector2(2, 2)
texture = ExtResource("3_1tlhv")
region_enabled = true
region_rect = Rect2(96, 4, 8, 24)
[node name="YellowChip" type="Sprite2D" parent="Player2Card/Right"]
position = Vector2(-7, 6)
scale = Vector2(0.25, 0.25)
texture = ExtResource("5_i2o8i")
[node name="Name" type="Label" parent="Player2Card"]
offset_left = -531.0
offset_top = -240.0
offset_right = -429.0
offset_bottom = -224.0
theme_override_fonts/font = ExtResource("3_rjcmr")
text = "Player 2"
[node name="Status" type="Label" parent="Player2Card"]
offset_left = -530.0
offset_top = -220.0
offset_right = -428.0
offset_bottom = -212.0
theme_override_fonts/font = ExtResource("3_rjcmr")
theme_override_font_sizes/font_size = 8
text = "THINKING"
script = ExtResource("5_wjs8a")
[node name="TileMap" type="TileMap" parent="."]
position = Vector2(39, 200)
scale = Vector2(3, 3)
tile_set = SubResource("TileSet_glh1q")
format = 2
layer_0/z_index = 100
layer_0/tile_data = PackedInt32Array(-3, 0, 0, -2, 0, 0, -1, 0, 0, -65536, 0, 0, -65535, 0, 0, -65534, 0, 0, -4, 0, 0, -65540, 0, 0, -131076, 0, 0, -196612, 0, 0, -262148, 0, 0, -327684, 0, 0, -327683, 0, 0, -327682, 0, 0, -327681, 0, 0, -393216, 0, 0, -393215, 0, 0, -393214, 0, 0, -327678, 0, 0, -262142, 0, 0, -196606, 0, 0, -131070, 0, 0, -131071, 0, 0, -131072, 0, 0, -65537, 0, 0, -65538, 0, 0, -65539, 0, 0, -131075, 0, 0, -131074, 0, 0, -131073, 0, 0, -196608, 0, 0, -196607, 0, 0, -262143, 0, 0, -262144, 0, 0, -196609, 0, 0, -196610, 0, 0, -196611, 0, 0, -262147, 0, 0, -262146, 0, 0, -262145, 0, 0, -327680, 0, 0, -327679, 0, 0, -5, 196608, 536870912, -65541, 65536, 0, -131077, 65536, 0, -196613, 65536, 0, -262149, 65536, 0, -327685, 196608, 0, 65532, 196608, 1610612736, 2, 196608, 1879048192, -65533, 196608, 805306368, -393213, 196608, 268435456, -458750, 196608, 1342177280, -393219, 65536, 1073741824, -393218, 65536, 1073741824, -393217, 65536, 1073741824, -458752, 65536, 1073741824, -458751, 65536, 1073741824, -393220, 196608, 1073741824, -327677, 65536, 268435456, -262141, 65536, 268435456, -196605, 65536, 268435456, -131069, 65536, 268435456, 65533, 65536, 1879048192, 65534, 65536, 1879048192, 65535, 65536, 1879048192, 0, 65536, 1879048192, 1, 65536, 1879048192)
[node name="BracketButton" parent="." instance=ExtResource("7_glh1q")]
offset_left = -566.0
offset_top = 281.0
offset_right = -550.0
offset_bottom = 297.0
script = ExtResource("8_u1oi2")
[node name="Sprite2D" type="Sprite2D" parent="BracketButton"]
position = Vector2(8, 8)
texture = SubResource("AtlasTexture_glh1q")
+114
View File
@@ -0,0 +1,114 @@
[gd_scene load_steps=8 format=3 uid="uid://rl33x81cxlh0"]
[ext_resource type="Theme" uid="uid://bbgxacei1vwba" path="res://assets/theme.tres" id="1_as653"]
[ext_resource type="Script" uid="uid://dm25u0a2lqk2x" path="res://scripts/BracketScene.cs" id="1_dvj3m"]
[ext_resource type="Texture2D" uid="uid://da13ksuf4vkqe" path="res://assets/sprites/observe.png" id="2_mbqc8"]
[ext_resource type="Texture2D" uid="uid://stk7umv2ppss" path="res://assets/sprites/cancel.png" id="3_as653"]
[ext_resource type="Script" uid="uid://1y72woiynf31" path="res://scripts/AdminControls.cs" id="4_mbqc8"]
[sub_resource type="Gradient" id="Gradient_wu84c"]
colors = PackedColorArray(0, 0.07058824, 0.101960786, 1, 0.39215687, 0.39215687, 0.39215687, 1)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_as653"]
gradient = SubResource("Gradient_wu84c")
width = 1024
height = 1024
fill_from = Vector2(0.5, 1)
fill_to = Vector2(0.5, 0)
metadata/_snap_enabled = true
[node name="BracketView" type="Control" node_paths=PackedStringArray("Players", "Matches")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_as653")
script = ExtResource("1_dvj3m")
Players = NodePath("HBoxContainer/PlayerList")
Matches = NodePath("HBoxContainer/MatchList")
WatchButton = ExtResource("2_mbqc8")
TerminateKickButton = ExtResource("3_as653")
[node name="Background" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -152.0
offset_bottom = 152.0
grow_horizontal = 2
grow_vertical = 2
texture = SubResource("GradientTexture2D_as653")
[node name="ColorRect" type="ColorRect" parent="."]
custom_minimum_size = Vector2(0, 36)
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
color = Color(0.13319641, 0.13319641, 0.13319638, 1)
[node name="AdminControls" type="HBoxContainer" parent="ColorRect" node_paths=PackedStringArray("BecomeAdmin", "StartTournament", "CancelTournament", "Label", "Timeout")]
custom_minimum_size = Vector2(0, 36)
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 36.0
grow_horizontal = 2
script = ExtResource("4_mbqc8")
BecomeAdmin = NodePath("BecomeAdmin")
StartTournament = NodePath("StartTournament")
CancelTournament = NodePath("CancelTournament")
Label = NodePath("Label")
Timeout = NodePath("HSlider")
[node name="BecomeAdmin" type="Button" parent="ColorRect/AdminControls"]
layout_mode = 2
text = "Become Admin"
[node name="StartTournament" type="Button" parent="ColorRect/AdminControls"]
layout_mode = 2
text = "Start Tournament"
[node name="CancelTournament" type="Button" parent="ColorRect/AdminControls"]
layout_mode = 2
text = "Cancel Tournament"
[node name="Label" type="Label" parent="ColorRect/AdminControls"]
layout_mode = 2
text = "Wait To Move: 5.0s "
[node name="HSlider" type="HSlider" parent="ColorRect/AdminControls"]
custom_minimum_size = Vector2(256, 0)
layout_mode = 2
size_flags_vertical = 1
min_value = 0.1
max_value = 5.0
step = 0.2
value = 5.0
tick_count = 25
ticks_on_borders = true
[node name="HBoxContainer" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = -1
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 36.0
grow_horizontal = 2
grow_vertical = 2
[node name="PlayerList" type="Tree" parent="HBoxContainer"]
custom_minimum_size = Vector2(400, 0)
layout_mode = 2
columns = 3
column_titles_visible = true
scroll_horizontal_enabled = false
[node name="MatchList" type="Tree" parent="HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
columns = 3
column_titles_visible = true
+44
View File
@@ -0,0 +1,44 @@
[gd_scene load_steps=7 format=3 uid="uid://d1wr0v5ht8vqb"]
[ext_resource type="Script" uid="uid://frisgjvf17ym" path="res://scripts/button_medium.gd" id="2_06p6p"]
[ext_resource type="Texture2D" uid="uid://dlx02qat7j6lf" path="res://assets/sprites/AssetTileset.png" id="2_q2stu"]
[ext_resource type="FontFile" uid="uid://c3jmev24lo6ci" path="res://assets/fonts/PixelOperator8.ttf" id="3_dxjfy"]
[sub_resource type="AtlasTexture" id="AtlasTexture_6ptbq"]
atlas = ExtResource("2_q2stu")
region = Rect2(8, 16, 32, 16)
[sub_resource type="AtlasTexture" id="AtlasTexture_06p6p"]
atlas = ExtResource("2_q2stu")
region = Rect2(8, 32, 32, 16)
[sub_resource type="AtlasTexture" id="AtlasTexture_q2stu"]
atlas = ExtResource("2_q2stu")
region = Rect2(8, 0, 32, 16)
[node name="ButtonSmall" type="TextureButton"]
offset_left = -32.0
offset_top = -32.0
offset_right = 32.0
texture_normal = SubResource("AtlasTexture_6ptbq")
texture_pressed = SubResource("AtlasTexture_06p6p")
texture_hover = SubResource("AtlasTexture_q2stu")
stretch_mode = 4
script = ExtResource("2_06p6p")
[node name="Label" type="Label" parent="."]
layout_mode = 0
offset_left = 2.0
offset_top = 9.0
offset_right = 62.0
offset_bottom = 25.0
theme_override_colors/font_color = Color(2.7723312e-05, 0.60865843, 0.9772685, 1)
theme_override_fonts/font = ExtResource("3_dxjfy")
theme_override_font_sizes/font_size = 16
text = "TEMP"
horizontal_alignment = 1
[connection signal="button_down" from="." to="." method="onButtonDown"]
[connection signal="button_up" from="." to="." method="onButtonUp"]
[connection signal="mouse_entered" from="." to="." method="onMouseEnter"]
[connection signal="mouse_exited" from="." to="." method="onMouseExit"]
+24
View File
@@ -0,0 +1,24 @@
[gd_scene load_steps=5 format=3 uid="uid://pdean68jjg80"]
[ext_resource type="Texture2D" uid="uid://dlx02qat7j6lf" path="res://assets/sprites/AssetTileset.png" id="2_q2stu"]
[sub_resource type="AtlasTexture" id="AtlasTexture_qlcsu"]
atlas = ExtResource("2_q2stu")
region = Rect2(0, 64, 16, 16)
[sub_resource type="AtlasTexture" id="AtlasTexture_6ptbq"]
atlas = ExtResource("2_q2stu")
region = Rect2(0, 80, 16, 16)
[sub_resource type="AtlasTexture" id="AtlasTexture_06p6p"]
atlas = ExtResource("2_q2stu")
region = Rect2(0, 48, 16, 16)
[node name="ButtonSmall" type="TextureButton"]
offset_left = -16.0
offset_top = -32.0
offset_bottom = -16.0
scale = Vector2(2, 2)
texture_normal = SubResource("AtlasTexture_qlcsu")
texture_pressed = SubResource("AtlasTexture_6ptbq")
texture_hover = SubResource("AtlasTexture_06p6p")
+18
View File
@@ -0,0 +1,18 @@
[gd_scene load_steps=3 format=3 uid="uid://cct663hb47yka"]
[ext_resource type="PackedScene" uid="uid://d1wr0v5ht8vqb" path="res://scenes/button_medium.tscn" id="1_4km6l"]
[ext_resource type="Script" uid="uid://b1ogflafdte71" path="res://scripts/create_join_room.gd" id="1_k6yuv"]
[node name="CreateJoinRoom" type="Node2D"]
position = Vector2(0, 42)
script = ExtResource("1_k6yuv")
[node name="JoinGameButton" parent="." instance=ExtResource("1_4km6l")]
offset_top = 17.0
offset_bottom = 49.0
metadata/_edit_use_anchors_ = true
[node name="CreateGameButton" parent="." instance=ExtResource("1_4km6l")]
offset_top = 85.0
offset_bottom = 117.0
metadata/_edit_use_anchors_ = true
+38
View File
@@ -0,0 +1,38 @@
[gd_scene load_steps=5 format=3 uid="uid://cr8fi0e4r88s8"]
[ext_resource type="PackedScene" uid="uid://cct663hb47yka" path="res://scenes/create_join_room.tscn" id="1_yqjtg"]
[ext_resource type="PackedScene" uid="uid://m542qwlp7hl7" path="res://scenes/board_screen.tscn" id="2_lnu2h"]
[sub_resource type="Gradient" id="Gradient_wu84c"]
colors = PackedColorArray(0, 0.07058824, 0.101960786, 1, 0.39215687, 0.39215687, 0.39215687, 1)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_yqjtg"]
gradient = SubResource("Gradient_wu84c")
width = 1024
height = 1024
fill_from = Vector2(0.5, 1)
fill_to = Vector2(0.5, 0)
metadata/_snap_enabled = true
[node name="Game" type="Node2D"]
[node name="Background" type="TextureRect" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -658.0
offset_top = -547.0
offset_right = 668.0
offset_bottom = 657.0
grow_horizontal = 2
grow_vertical = 2
texture = SubResource("GradientTexture2D_yqjtg")
[node name="GameManager" type="Node" parent="."]
[node name="Camera2D" type="Camera2D" parent="."]
[node name="CreateJoinRoom" parent="." instance=ExtResource("1_yqjtg")]
visible = false
[node name="BoardScreen" parent="." instance=ExtResource("2_lnu2h")]
+148
View File
@@ -0,0 +1,148 @@
[gd_scene load_steps=7 format=3 uid="uid://dcx5nvs0pa7me"]
[ext_resource type="Script" uid="uid://bk22f71oximjk" path="res://scripts/AddressUI.cs" id="1_l6cm7"]
[ext_resource type="Theme" uid="uid://bbgxacei1vwba" path="res://assets/theme.tres" id="1_wu84c"]
[ext_resource type="Script" uid="uid://cpjbiqn26khck" path="res://scripts/ConnectButtonUI.cs" id="2_ekxnf"]
[ext_resource type="Texture2D" uid="uid://uritd4ygetrk" path="res://assets/sprites/rpi.png" id="3_bqqt6"]
[sub_resource type="Gradient" id="Gradient_wu84c"]
colors = PackedColorArray(0, 0.07058824, 0.101960786, 1, 0.39215687, 0.39215687, 0.39215687, 1)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_yqjtg"]
gradient = SubResource("Gradient_wu84c")
width = 1024
height = 1024
fill_from = Vector2(0.5, 1)
fill_to = Vector2(0.5, 0)
metadata/_snap_enabled = true
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Background" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
texture = SubResource("GradientTexture2D_yqjtg")
[node name="RPI Minds and Machines" type="Label" parent="."]
layout_mode = 1
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -193.5
offset_top = 128.0
offset_right = 193.5
offset_bottom = 152.0
grow_horizontal = 2
theme = ExtResource("1_wu84c")
theme_type_variation = &"HeaderMedium"
text = "RPI Minds & Machines"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Connect" type="Label" parent="."]
layout_mode = 1
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -100.0
offset_top = 200.0
offset_right = 68.00122
offset_bottom = 229.0
grow_horizontal = 2
theme = ExtResource("1_wu84c")
theme_type_variation = &"HeaderLarge"
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 16
text = "Connect"
[node name="4" type="Label" parent="."]
layout_mode = 1
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = 78.0
offset_top = 200.0
offset_right = 118.00116
offset_bottom = 229.0
grow_horizontal = 2
theme = ExtResource("1_wu84c")
theme_type_variation = &"HeaderLarge"
theme_override_colors/font_color = Color(1, 0, 0, 1)
theme_override_constants/outline_size = 16
text = "4"
[node name="Address" type="TextEdit" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -250.0
offset_top = -20.0
offset_right = 250.0
offset_bottom = 20.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_wu84c")
placeholder_text = "Server Address"
emoji_menu_enabled = false
scroll_smooth = true
script = ExtResource("1_l6cm7")
[node name="Button" type="Button" parent="." node_paths=PackedStringArray("AddressField", "ErrorLabel")]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -250.0
offset_top = 27.25
offset_right = 250.0
offset_bottom = 67.25
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_wu84c")
text = "Connect"
script = ExtResource("2_ekxnf")
AddressField = NodePath("../Address")
ErrorLabel = NodePath("../Label")
[node name="Label" type="Label" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -68.5
offset_top = 75.149994
offset_right = 68.5
offset_bottom = 98.149994
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_wu84c")
theme_override_colors/font_color = Color(1, 0, 0, 1)
[node name="TextureRect" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 5.0
offset_top = -69.0
offset_right = 69.0
offset_bottom = -5.0
grow_vertical = 0
texture = ExtResource("3_bqqt6")
+35
View File
@@ -0,0 +1,35 @@
[gd_scene load_steps=11 format=3 uid="uid://b4tujjdhmk4h"]
[ext_resource type="Texture2D" uid="uid://ckmfi0cjgxgyk" path="res://assets/sprites/RedChip.png" id="1_qsflu"]
[ext_resource type="Script" uid="uid://dd5nu6037qsr0" path="res://scripts/chip_sfx.gd" id="1_tfypd"]
[ext_resource type="AudioStream" uid="uid://dauf0pi1pkd3x" path="res://assets/sfx/chip_collide_1.ogg" id="2_g7r6w"]
[ext_resource type="AudioStream" uid="uid://b6b7b7sc038n7" path="res://assets/sfx/chip_collide_2.ogg" id="3_l66m7"]
[ext_resource type="AudioStream" uid="uid://c6a4wqoopu53j" path="res://assets/sfx/chip_collide_3.ogg" id="4_5isma"]
[ext_resource type="AudioStream" uid="uid://5liedrachob2" path="res://assets/sfx/chip_collide_4.ogg" id="5_sa1hx"]
[ext_resource type="AudioStream" uid="uid://dsyovynhhbmw8" path="res://assets/sfx/chip_collide_5.ogg" id="6_752ap"]
[ext_resource type="AudioStream" uid="uid://c0a5rl5q04noq" path="res://assets/sfx/chip_collide_6.ogg" id="7_ky42j"]
[ext_resource type="AudioStream" uid="uid://b6d73wiiqxles" path="res://assets/sfx/chip_collide_7.ogg" id="8_lgcne"]
[sub_resource type="CircleShape2D" id="CircleShape2D_tfypd"]
radius = 12.8
[node name="RedChip" type="RigidBody2D" node_paths=PackedStringArray("audio_stream_player_2d")]
lock_rotation = true
contact_monitor = true
max_contacts_reported = 3
script = ExtResource("1_tfypd")
audio_stream_player_2d = NodePath("AudioStreamPlayer2D")
sounds = Array[AudioStream]([ExtResource("2_g7r6w"), ExtResource("3_l66m7"), ExtResource("4_5isma"), ExtResource("5_sa1hx"), ExtResource("6_752ap"), ExtResource("7_ky42j"), ExtResource("8_lgcne")])
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(3, 3)
texture = ExtResource("1_qsflu")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
scale = Vector2(3, 3)
shape = SubResource("CircleShape2D_tfypd")
[node name="AudioStreamPlayer2D" type="AudioStreamPlayer2D" parent="."]
volume_db = -5.0
[connection signal="body_entered" from="." to="." method="_on_body_entered"]
+35
View File
@@ -0,0 +1,35 @@
[gd_scene load_steps=11 format=3 uid="uid://lruk652t0xe5"]
[ext_resource type="Script" uid="uid://dd5nu6037qsr0" path="res://scripts/chip_sfx.gd" id="1_epi6l"]
[ext_resource type="Texture2D" uid="uid://qy30emdgrk7o" path="res://assets/sprites/YellowChip.png" id="1_eu0sq"]
[ext_resource type="AudioStream" uid="uid://dauf0pi1pkd3x" path="res://assets/sfx/chip_collide_1.ogg" id="2_ki13g"]
[ext_resource type="AudioStream" uid="uid://b6b7b7sc038n7" path="res://assets/sfx/chip_collide_2.ogg" id="3_kic5w"]
[ext_resource type="AudioStream" uid="uid://c6a4wqoopu53j" path="res://assets/sfx/chip_collide_3.ogg" id="4_nbcpr"]
[ext_resource type="AudioStream" uid="uid://5liedrachob2" path="res://assets/sfx/chip_collide_4.ogg" id="5_wq2sm"]
[ext_resource type="AudioStream" uid="uid://dsyovynhhbmw8" path="res://assets/sfx/chip_collide_5.ogg" id="6_ik045"]
[ext_resource type="AudioStream" uid="uid://c0a5rl5q04noq" path="res://assets/sfx/chip_collide_6.ogg" id="7_evobg"]
[ext_resource type="AudioStream" uid="uid://b6d73wiiqxles" path="res://assets/sfx/chip_collide_7.ogg" id="8_b44ux"]
[sub_resource type="CircleShape2D" id="CircleShape2D_epi6l"]
radius = 12.8
[node name="YellowChip" type="RigidBody2D" node_paths=PackedStringArray("audio_stream_player_2d")]
lock_rotation = true
contact_monitor = true
max_contacts_reported = 3
script = ExtResource("1_epi6l")
audio_stream_player_2d = NodePath("AudioStreamPlayer2D")
sounds = Array[AudioStream]([ExtResource("2_ki13g"), ExtResource("3_kic5w"), ExtResource("4_nbcpr"), ExtResource("5_wq2sm"), ExtResource("6_ik045"), ExtResource("7_evobg"), ExtResource("8_b44ux")])
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(3, 3)
texture = ExtResource("1_eu0sq")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
scale = Vector2(3, 3)
shape = SubResource("CircleShape2D_epi6l")
[node name="AudioStreamPlayer2D" type="AudioStreamPlayer2D" parent="."]
volume_db = -5.0
[connection signal="body_entered" from="." to="." method="_on_body_entered"]
+6
View File
@@ -0,0 +1,6 @@
using Godot;
using System;
public partial class AddressUI : TextEdit {
public override void _Ready() { Text = Connection.WS_DEFAULT_ADDRESS; }
}
+1
View File
@@ -0,0 +1 @@
uid://bk22f71oximjk
+133
View File
@@ -0,0 +1,133 @@
using Godot;
public partial class AdminControls : HBoxContainer {
[Export] public Button BecomeAdmin;
[Export] public Button StartTournament;
[Export] public Button CancelTournament;
[Export] public Label Label;
[Export] public Slider Timeout;
public override void _Ready() {
Connection.Instance.OnBecomeAdmin += UpdateUI;
Connection.Instance.OnTournamentEnd += UpdateUI;
Connection.Instance.OnStartTournament += UpdateUI;
Connection.Instance.OnCancelTournamentAck += UpdateUI;
Connection.Instance.OnGetDataAcks += UpdateUI;
Connection.Instance.OnSetDataAcks += UpdateUI;
StartTournament.Pressed += () => Connection.Instance.StartTournament();
CancelTournament.Pressed += () => Connection.Instance.CancelTournament();
UpdateUI();
Timeout.ValueChanged += value =>
{
Connection.Instance.SetMoveWait((float)value);
var time = Connection.Instance.CurrentWaitTimeout.ToString();
if (time.Length > 3) {
time = time.Substring(0, 3);
}
Label.Text = "Wait To Move: " + time + "s ";
};
BecomeAdmin.Pressed += showAuthPopup;
}
public override void _ExitTree() {
Connection.Instance.OnBecomeAdmin -= UpdateUI;
Connection.Instance.OnTournamentEnd -= UpdateUI;
Connection.Instance.OnStartTournament -= UpdateUI;
Connection.Instance.OnCancelTournamentAck -= UpdateUI;
Connection.Instance.OnGetDataAcks -= UpdateUI;
Connection.Instance.OnSetDataAcks -= UpdateUI;
}
private void UpdateUI() {
if (!Connection.Instance.IsAdmin) {
BecomeAdmin.Show();
StartTournament.Hide();
CancelTournament.Hide();
Label.Hide();
Timeout.Hide();
} else {
BecomeAdmin.Hide();
Label.Show();
Timeout.Show();
}
if (Connection.Instance.IsAdmin && Connection.Instance.DemoMode) {
StartTournament.Hide();
CancelTournament.Hide();
} else if (Connection.Instance.IsAdmin && Connection.Instance.ActiveTournament != TournamentType.None) {
StartTournament.Hide();
CancelTournament.Show();
} else if (Connection.Instance.IsAdmin) {
StartTournament.Show();
CancelTournament.Hide();
}
Timeout.Value = Connection.Instance.CurrentWaitTimeout;
var time = Connection.Instance.CurrentWaitTimeout.ToString();
if (time.Length > 3) {
time = time.Substring(0, 3);
}
Label.Text = "Wait To Move: " + time + "s ";
}
private void showAuthPopup() {
var authWindow = new Window();
authWindow.Theme = GD.Load<Theme>("res://assets/theme.tres");
authWindow.AlwaysOnTop = true;
authWindow.MaximizeDisabled = true;
authWindow.Unresizable = true;
authWindow.InitialPosition = Window.WindowInitialPosition.CenterMainWindowScreen;
authWindow.Size = new Vector2I(256, 128);
authWindow.CloseRequested += () =>
{
GetTree().Root.CallDeferred(Node.MethodName.RemoveChild, authWindow);
};
var vbox = new VBoxContainer();
vbox.LayoutMode = 1;
vbox.AnchorBottom = 1.0f;
vbox.AnchorRight = 1.0f;
vbox.GrowHorizontal = GrowDirection.Both;
vbox.GrowVertical = GrowDirection.Both;
vbox.Alignment = AlignmentMode.Center;
var passwordBox = new TextEdit();
passwordBox.PlaceholderText = "Password";
passwordBox.SetCustomMinimumSize(new Vector2(32, 32));
passwordBox.GuiInput += e =>
{
if (passwordBox.HasFocus() && e is InputEventKey inputEventKey && inputEventKey.IsPressed()) {
if (inputEventKey.KeyLabel == Key.Enter) {
Connection.Instance.AdminAuth(passwordBox.Text);
GetTree().Root.CallDeferred(Node.MethodName.RemoveChild, authWindow);
GetViewport().SetInputAsHandled();
}
if (inputEventKey.KeyLabel == Key.Space) {
GetViewport().SetInputAsHandled();
}
}
};
var button = new Button();
button.Text = "Login";
button.Pressed += () =>
{
Connection.Instance.AdminAuth(passwordBox.Text);
GetTree().Root.CallDeferred(Node.MethodName.RemoveChild, authWindow);
};
vbox.AddChild(passwordBox);
vbox.AddChild(button);
authWindow.AddChild(vbox);
GetTree().Root.AddChild(authWindow);
}
}
+1
View File
@@ -0,0 +1 @@
uid://1y72woiynf31

Some files were not shown because too many files have changed in this diff Show More