Compare commits
50 Commits
main
..
old-observer
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={}
|
||||
@@ -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={}
|
||||
@@ -0,0 +1 @@
|
||||
jazz_music.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 430 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 636 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 668 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 151 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 167 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 170 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -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
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
docker build . -t joshuafhiggins/connect4-ui
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 75 KiB |
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const CHIP_DROP_SOUND_PATHS = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => `/sfx/chip_collide_${index + 1}.ogg`,
|
||||
);
|
||||
@@ -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.
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 266 KiB |
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")]
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
using Godot;
|
||||
using System;
|
||||
|
||||
public partial class AddressUI : TextEdit {
|
||||
public override void _Ready() { Text = Connection.WS_DEFAULT_ADDRESS; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bk22f71oximjk
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://1y72woiynf31
|
||||