Compare commits

...

21 Commits

Author SHA1 Message Date
a4792759e6 fix: CameraOrbitKeyboard + playersAlive HUD
- CameraOrbitKeyboard: clic droit = unlock, clic gauche = re-lock (coherent avec PlayerController)
- CameraOrbitKeyboard: bloque les inputs quand ChatUI est ouvert
- CameraOrbitKeyboard: OnEnable ne verrouille plus la souris si un panel UI est ouvert
- NetworkManager: alimente GameHUD.SetPlayersAlive via Listen(playersAlive)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:25:25 +02:00
e2fa2ba8a9 fix: chat input isolation, mouse lock, multi spawn
- PlayerController: block WASD/jump callbacks when ChatUI is open
- PlayerController: clic droit = unlock souris, clic gauche = re-lock (n'est plus un toggle)
- PlayerController: ajoute ResetInputs() appelé à l'ouverture du chat
- ChatUI: appelle ResetInputs() quand le panel s'ouvre pour éviter les touches collées
- NetworkManager: seed les joueurs déjà présents dans la room à la connexion
  (les OnAdd Colyseus peuvent être manqués si l'état est décodé avant l'enregistrement des callbacks)
- NetworkManager: garde anti-doublon dans OnPlayerAdd
- NetworkManager: fallback sphere si remotePlayerPrefab est null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:22:40 +02:00
aa27725c4e feat: nouveau build WebGL last_build + fixes stats et schema Colyseus
- Unity build last_build remplace build_mai
- NetworkSchema.cs: correction types sbyte pour int8 (fix OverflowException Colyseus)
- StatsTracker: envoi periodique toutes les 30s, plus de dependance aux round events
- StatsTracker: cooldown client 6s pour respecter le rate-limit serveur
- StatsPage: correction row.value au lieu de row[activeTab]
- StatsPage: suppression onglet Courses (racesPlayed)
- Backend index.js: logging POST /stats/update
- Scene Tutorial: mise a jour, suppression assets obsoletes (TutorialInfo, physicMaterials)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:12:14 +02:00
cf7d73ba08 docs: add README with badges and architecture overview 2026-05-18 00:03:56 +02:00
83544fe3d2 fix: supprimer le timer race timeout de 3 minutes
RACE_TIMEOUT = 180s eliminait tous les joueurs non qualifies apres 3min.
Le round se termine maintenant uniquement quand suffisamment de joueurs
se qualifient (ratio 60%).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:34:02 +02:00
391c000a73 feat: systeme de lobby avec liste de rooms
Backend:
- GET /rooms via matchMaker.query() pour lister les salles actives
- ArenaRoom: setMetadata avec nom de salle (Salle #<id6>)

NetworkManager:
- FetchRooms() / OnRoomsRefreshed event (UnityWebRequest GET /rooms)
- JoinByRoomId(), CreateRoom() en plus de JoinArena()
- Refactoring: PrepareJoin/FinishJoin/HandleJoinError pour eviter duplication

LobbyUI:
- Redesign: panel 620x520 avec setup perso (gauche) + liste rooms (droite)
- Bouton Rejoindre par salle, Creer une salle, Rejoindre n importe
- Pseudo pre-rempli depuis PlayerPrefs
- Refresh automatique toutes les 4s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:14:27 +02:00
44b758360c remove: scripts CheckpointSystem, CheckpointTrigger, EliminationOverlay
Nettoie aussi les references dans GameManager, GameHUD et StatsTracker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:59:45 +02:00
385b4f690e fix: stats jamais envoyees - OnDisconnected avant Cleanup + cache du nom joueur
NetworkManager: inverser ordre OnDisconnected/Cleanup pour que les listeners
aient encore acces a LocalPlayerName au moment du callback.

StatsTracker: mettre en cache le nom a la connexion comme fallback supplementaire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:55:01 +02:00
b3651f8027 fix: gameServer.define() n'est pas async dans Colyseus 0.17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:58:47 +02:00
7327f073d8 fix: remapper F1/F2/F3 -> backtick/Tab/T pour compatibilite WebGL navigateur
- DebugNetworkUI: F1 -> backtick (`)
- KeyBindingUI: F2 -> Tab
- ChatUI: F3 -> T (ouvrir seulement), Escape pour fermer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:53:07 +02:00
b993c6b3e6 fix: chat PlayerName fallback + nouveau build WebGL 20260517b
- ChatUI: PlayerName utilise PlayerPrefs comme fallback si non connecte
- NetworkManager: sauvegarde le nom dans PlayerPrefs a la connexion
- GameCanvas: version build bump -> 20260517b (cache bust)
- Nouveaux fichiers build_mai (data + wasm mis a jour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:21:00 +02:00
e0da6c4f00 scene: ajout ChatUI sur NetworkManager, StatsTracker sur PlayerSphere
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:02:09 +02:00
597bfe1723 fix: chat envoie toujours via HTTP, pas Colyseus
Colyseus disconnectait le client si le handler chat n'était pas
déployé côté serveur. Le endpoint HTTP /chat/send broadcaste déjà
dans les rooms Colyseus, donc le path Colyseus est superflu.
Ajout poll immédiat après envoi pour affichage sans délai.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:01:48 +02:00
01d6128209 feat: WebGL build mai 2026 + GameCanvas update
- Ajout build_mai.{data,framework.js,loader.js,wasm} dans public/unity-build/Build
- GameCanvas.jsx: préfixe build_mai, version 20260517, suppression SetServerURL (hardcodé côté Unity)
- .gitignore: exception pour frontend/public/unity-build/, règle build_mai/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:46:35 +02:00
5c98f1638a feat: stats + chat + frontend pages (Stats, Chat, NavBar)
Backend:
- StatsManager.js: JSON persistence, leaderboard, rate-limit 1/5s
- ChatManager.js: 200-msg buffer, JSON persistence
- index.js: routes GET/POST /stats, /chat/history, /chat/send (Zod validation)
- ArenaRoom.js: chat handler broadcasts to room + persists via ChatManager

Unity:
- StatsTracker.cs: distance, maxSpeed, jumps, bumps, checkpoints, raceTime tracking
- ChatUI.cs: F3 toggle, bottom-right panel, polling 3s, unread badge
- NetworkManager.cs: SendChatMessage() + OnMessage<ChatUI.ChatMessage>(chat)
- CheckpointSystem.cs: RegisterCheckpoint/Finish hooks
- PlayerController.cs: RegisterJump/Bump hooks, physics rebalance, billboard fix
- GameHUD.cs: LocalRaceTimer, SetTotalRounds, OnRoundStart signature fix
- GameManager.cs: spectator cam reconnect fix

Frontend:
- NavBar.jsx: fixed top nav, Accueil/Stats/Chat/Jouer
- App.jsx: page state (home/play/stats/chat) + NavBar
- StatsPage.jsx: 6-tab leaderboard, auto-refresh 30s
- ChatPage.jsx: polling 3s, localStorage name, Enter to send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:33:06 +02:00
526d30c569 Fix physique boule (moins de drag) + billboard name tags vers caméra
- turnDamping 7→1.5, idleDrag 3→0.2 : comportement boule plus naturel
- Fix billboard : LookRotation inversé (texte faisant face à la caméra, pas dos)
  s'applique aux labels local (PlayerController) et remote (RemotePlayerController)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:47:13 +02:00
c835f932b0 Fix GameHUD.OnRoundStart signature (ajoute totalRounds)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:35:10 +02:00
4743c307a6 Supprime les mini-jeux (survival/teams), corrige tous les bugs identifiés
- Supprime DeathZone.cs, ZoneCapture.cs, ArenaZoneBuilder.cs
- ArenaRoom.js : mode race uniquement, fix _checkRoundEndCondition, fix _getActiveCount
- GameState.js : supprime team (Player) et deathZoneY/teamScoreRed/teamScoreBlue (GameState)
- NetworkSchema.cs : aligne sur le nouveau schéma serveur (supprime team, indices corrigés)
- NetworkManager.cs : supprime OnDeathZoneYChanged/SendDeathZoneHit/SendInZone, OnRoundStart passe totalRounds
- GameManager.cs : subscriptions OnEnable→Start/OnDestroy, fix Lobby (player visible si connecté), HandleRoundStart(totalRounds)
- GameHUD.cs : supprime blocs survival/teams, ajoute SetTotalRounds, supprime dead code
- PlayerController.cs : cache Rigidbody, fix OnCollisionStay gel (supprime else), SetSpawnPosition
- CheckpointSystem.cs : flash le prochain checkpoint actif, supprime FinishFlash vide
- LobbyUI.cs : CancelInvoke sur connexion, appelle SetSpawnPosition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:28:37 +02:00
456d876847 Fix: NetworkSchema — supprimer userId fantome, corriger ordre isReady
userId [Type(0)] inexistant cote serveur decalait tous les champs d un
cran : x recevait y, y recevait z, positions et rotations toutes fausses.
isReady etait en [Type(23)] alors que le serveur l envoie en index 20.
Les deux bugs cassaient toute la synchronisation reseau multi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:21:06 +02:00
f62eeab50d Fix Z-fighting : corriger les Y de tous les objets overlapping
- Sol top=y=0, pads/checkpoints au sol relevés de 0.01 (bottom>=0.01)
- GelOrange_E/W, GelViolet_Trap, CaptureZone_Visual : y 0.2->0.21
- CP0_Start, CP4_Finish : y 0.15->0.16
- Plateforme élevée top=y=4.5, objets relevés au-dessus :
  CP1/CP2_Mid/CP3 : y 4.15->4.66, GelBleu_Elev : y 4.2->4.71

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:02:06 +02:00
7aa4a518db Steering, HUD course, auto-index checkpoints
- PlayerController: MovementSpeed 5->25, ajout turnDamping+idleDrag pour virages nets
- CheckpointSystem: auto-assign checkpointIndex depuis l'array, déclenche race HUD sur CP0
- GameHUD: course visible dès passage de la porte (CP0), timer local indépendant du serveur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:52:36 +02:00
73 changed files with 16198 additions and 21048 deletions

5
.gitignore vendored
View File

@@ -22,6 +22,11 @@ frontend/dist/
# WebGL build artifacts (generated by Unity)
build/
nouveau_build/
build_mai/
# Exception: frontend unity-build static assets (committed for deployment)
!frontend/public/unity-build/
!frontend/public/unity-build/**
# Dev-only tools (buildgate was used for manual dev deploys, remplacé par Coolify)
rolld_buildgate/

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
<div align="center">
<h1>ROLL'D</h1>
<p><strong>Browser-based marble MMO — multiplayer physics, real-time leaderboards, playable directly in your browser.</strong></p>
<p>
<img src="https://img.shields.io/badge/Unity-6000.0-black?style=for-the-badge&logo=unity&logoColor=white" alt="Unity 6" />
<img src="https://img.shields.io/badge/WebGL-build-E34F26?style=for-the-badge&logo=webgl&logoColor=white" alt="WebGL" />
<img src="https://img.shields.io/badge/Colyseus-0.17-6C47FF?style=for-the-badge&logo=node.js&logoColor=white" alt="Colyseus" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react&logoColor=black" alt="React" />
<img src="https://img.shields.io/badge/Vite-5-646CFF?style=for-the-badge&logo=vite&logoColor=white" alt="Vite" />
<img src="https://img.shields.io/badge/Tailwind_CSS-3-38BDF8?style=for-the-badge&logo=tailwindcss&logoColor=white" alt="Tailwind" />
</p>
<p>
<a href="https://rolld.kerboul.me"><img src="https://img.shields.io/badge/Play_Now-rolld.kerboul.me-22c55e?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Play Now" /></a>
</p>
</div>
---
## What is ROLL'D?
ROLL'D is a multiplayer marble game that runs entirely in the browser via Unity WebGL. Players control a physics-based ball in a shared 3D arena, competing for distance, speed, and style. The game features real-time synchronisation at 60 Hz, in-game chat, and persistent leaderboards.
No install. No account. Just open the page and roll.
---
## Features
- **Real-time multiplayer** - up to 20 players per room, 60 Hz state sync via Colyseus WebSockets
- **Physics-based gameplay** - Unity Rigidbody, jump charge, gel pads (speed boosts), ball-to-ball bumps
- **Room lobby** - browse open rooms, create your own, choose your colour and name
- **In-game chat** - accessible in-game (T key) and on the dedicated website chat page
- **Live leaderboards** - distance, max speed, jumps, bumps, playtime, updated every 30 seconds
- **Spectator camera** - orbiting camera while in lobby or after disconnecting
- **WebGL-native** - no plugins, no downloads, runs in Chrome/Firefox/Edge
---
## Architecture
```
rolld/
├── game/ # Unity 6 project (WebGL build)
│ └── Assets/Scripts/
│ ├── Network/ # Colyseus SDK integration, schema, lobby UI
│ ├── Stats/ # StatsTracker - periodic HTTP upload
│ └── UI/ # IMGUI in-game HUD, chat, keybinds
├── rolld_backend/game/ # Colyseus 0.17 game server (Node.js)
│ └── src/
│ ├── rooms/ # ArenaRoom - game state machine
│ ├── schema/ # Colyseus schema (Player + GameState)
│ ├── stats/ # StatsManager - JSON persistence
│ └── chat/ # ChatManager - in-memory history
└── frontend/ # React + Vite + Tailwind SPA
└── src/
├── pages/ # Home, Stats leaderboard, Chat
└── components/ # NavBar, GameCanvas (Unity embed)
```
### Network flow
```
Browser
└── Unity WebGL (GameCanvas iframe)
└── Colyseus SDK (WebSocket wss://)
└── ArenaRoom (Node.js)
└── Broadcast state @60 Hz
Browser
└── React SPA
└── REST API (HTTPS)
├── GET /stats/leaderboard/:key
├── GET /chat/history?since=
└── POST /stats/update (from Unity every 30s)
```
---
## Tech stack
| Layer | Technology |
|---|---|
| Game engine | Unity 6 LTS, C# |
| Multiplayer | Colyseus 0.17 (Node.js + WebSocket) |
| Frontend | React 19, Vite 5, Tailwind CSS 3 |
| Deployment | Docker, Coolify, nginx |
| Self-hosted | Proxmox LXC, Gitea, Traefik reverse proxy |
---
## Running locally
### Prerequisites
- Node.js 20+
- Unity 6000.x (for game builds only)
### Game server
```bash
cd rolld_backend/game
npm install
npm run dev
```
Server starts on `ws://localhost:2567`.
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Open `http://localhost:5173`. The frontend points to the production game server by default - edit `src/pages/StatsPage.jsx` and `src/components/GameCanvas.jsx` to switch to localhost.
### Unity (optional)
Open `game/` in Unity 6. The server URL is hardcoded in `Assets/Scripts/Network/NetworkManager.cs`. Switch to `wss://game.rolld.kerboul.me` for prod or `ws://localhost:2567` for local testing.
---
## Controls
| Key | Action |
|---|---|
| WASD / Arrow keys | Move |
| Space (hold) | Charge jump |
| Space (release) | Jump |
| T | Open chat |
| Escape | Close chat |
| Tab | Show keybindings |
| Backtick (`) | Debug network info |
---
## Live deployment
<p>
<img src="https://img.shields.io/badge/Frontend-rolld.kerboul.me-22c55e?style=flat-square&logo=nginx&logoColor=white" alt="Frontend" />
<img src="https://img.shields.io/badge/Game_server-game.rolld.kerboul.me-6C47FF?style=flat-square&logo=node.js&logoColor=white" alt="Game server" />
<img src="https://img.shields.io/badge/Self_hosted-Proxmox_homelab-E57000?style=flat-square&logo=proxmox&logoColor=white" alt="Self-hosted" />
</p>
The stack runs on a self-hosted Proxmox homelab cluster. Coolify handles container orchestration and auto-deployment on git push. Traefik manages HTTPS termination.
---
## Licence
MIT - do whatever you want with it.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,28 +1,41 @@
import { useState } from 'react'
import { IS_DEV } from './env'
import DevBanner from './components/DevBanner'
import NavBar from './components/NavBar'
import Hero from './components/Hero'
import GelShowcase from './components/GelShowcase'
import KerboulistanBanner from './components/KerboulistanBanner'
import GameCanvas from './components/GameCanvas'
import Footer from './components/Footer'
import StatsPage from './pages/StatsPage'
import ChatPage from './pages/ChatPage'
function App() {
const [isPlaying, setIsPlaying] = useState(false)
const [page, setPage] = useState('home')
if (isPlaying) {
return <GameCanvas onBack={() => setIsPlaying(false)} />
if (page === 'play') {
return <GameCanvas onBack={() => setPage('home')} />
}
return (
<div className="min-h-screen">
<DevBanner />
{/* Offset content when dev banner is visible */}
{IS_DEV && <div className="h-8" />}
<Hero onPlay={() => setIsPlaying(true)} />
<GelShowcase />
<KerboulistanBanner />
<Footer />
<NavBar page={page} setPage={setPage} />
{page === 'home' && (
<>
{IS_DEV && <div className="h-8" />}
<div className="pt-14">
<Hero onPlay={() => setPage('play')} />
<GelShowcase />
<KerboulistanBanner />
<Footer />
</div>
</>
)}
{page === 'stats' && <StatsPage />}
{page === 'chat' && <ChatPage />}
</div>
)
}

View File

@@ -3,11 +3,10 @@ import { useState, useEffect, useCallback } from 'react'
// Check if Unity build files exist
const UNITY_BUILD_PATH = '/unity-build/Build'
// Cache-busting version — update this after each Unity build
const UNITY_BUILD_VERSION = '20260310c'
const LOADER_URL = `${UNITY_BUILD_PATH}/nouveau_build.loader.js?v=${UNITY_BUILD_VERSION}`
const UNITY_BUILD_VERSION = '20260518'
const BUILD_PREFIX = 'last_build'
const LOADER_URL = `${UNITY_BUILD_PATH}/${BUILD_PREFIX}.loader.js?v=${UNITY_BUILD_VERSION}`
// Game server URL (Colyseus WebSocket)
const GAME_SERVER_URL = import.meta.env.VITE_GAME_SERVER_URL || 'ws://localhost:2567'
export default function GameCanvas({ onBack }) {
const [loadingProgress, setLoadingProgress] = useState(0)
@@ -39,9 +38,9 @@ export default function GameCanvas({ onBack }) {
if (typeof window.createUnityInstance === 'function') {
const canvas = document.getElementById('unity-canvas')
window.createUnityInstance(canvas, {
dataUrl: `${UNITY_BUILD_PATH}/nouveau_build.data?v=${UNITY_BUILD_VERSION}`,
frameworkUrl: `${UNITY_BUILD_PATH}/nouveau_build.framework.js?v=${UNITY_BUILD_VERSION}`,
codeUrl: `${UNITY_BUILD_PATH}/nouveau_build.wasm?v=${UNITY_BUILD_VERSION}`,
dataUrl: `${UNITY_BUILD_PATH}/${BUILD_PREFIX}.data?v=${UNITY_BUILD_VERSION}`,
frameworkUrl: `${UNITY_BUILD_PATH}/${BUILD_PREFIX}.framework.js?v=${UNITY_BUILD_VERSION}`,
codeUrl: `${UNITY_BUILD_PATH}/${BUILD_PREFIX}.wasm?v=${UNITY_BUILD_VERSION}`,
streamingAssetsUrl: '/unity-build/StreamingAssets',
companyName: 'ROLLD',
productName: 'ROLLD',
@@ -79,9 +78,7 @@ export default function GameCanvas({ onBack }) {
}
}
// Pass game server URL to Unity's NetworkManager
instance.SendMessage('NetworkManager', 'SetServerURL', GAME_SERVER_URL)
console.log('[ROLLD] Unity loaded, server URL sent:', GAME_SERVER_URL)
console.log('[ROLLD] Unity loaded')
}).catch((err) => {
setError(err.message)
})
@@ -153,10 +150,10 @@ export default function GameCanvas({ onBack }) {
</div>
<div className="glass rounded-xl p-4 text-left text-sm text-rolld-muted font-mono max-w-sm w-full">
<p className="text-rolld-accent-light mb-2">Fichiers requis :</p>
<p>├── nouveau_build.loader.js</p>
<p>├── nouveau_build.data</p>
<p>├── nouveau_build.framework.js</p>
<p>└── nouveau_build.wasm</p>
<p>├── build_mai.loader.js</p>
<p>├── build_mai.data</p>
<p>├── build_mai.framework.js</p>
<p>└── build_mai.wasm</p>
</div>
</div>
)}

View File

@@ -0,0 +1,50 @@
import { theme } from '../env'
const LINKS = [
{ id: 'home', label: 'Accueil' },
{ id: 'stats', label: 'Stats' },
{ id: 'chat', label: 'Chat' },
]
export default function NavBar({ page, setPage }) {
return (
<nav className="fixed top-0 left-0 right-0 z-50 glass border-b border-rolld-border/60">
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center justify-between">
<button
onClick={() => setPage('home')}
className="font-black text-lg tracking-tight"
style={{ color: theme.accent }}
>
ROLL'D
</button>
<div className="flex items-center gap-1">
{LINKS.map(link => (
<button
key={link.id}
onClick={() => setPage(link.id)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
page === link.id
? 'text-white'
: 'text-rolld-muted hover:text-rolld-text'
}`}
style={page === link.id ? { background: `rgba(${theme.accentRgb}, 0.2)`, color: theme.accentLight } : {}}
>
{link.label}
</button>
))}
<button
onClick={() => setPage('play')}
className="ml-3 px-4 py-2 rounded-xl text-sm font-bold text-white transition-all duration-200 hover:scale-105"
style={{
backgroundImage: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})`,
}}
>
Jouer
</button>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { theme } from '../env'
const SERVER = 'https://game.rolld.kerboul.me'
const POLL_INTERVAL = 3000
export default function ChatPage() {
const [messages, setMessages] = useState([])
const [inputText, setInputText] = useState('')
const [playerName, setPlayerName] = useState(() => localStorage.getItem('rolld_chat_name') || '')
const [editingName, setEditingName] = useState(!localStorage.getItem('rolld_chat_name'))
const [nameInput, setNameInput] = useState(playerName)
const [sending, setSending] = useState(false)
const lastTimestampRef = useRef(0)
const bottomRef = useRef(null)
const inputRef = useRef(null)
const fetchMessages = useCallback(async () => {
try {
const res = await fetch(`${SERVER}/chat/history?since=${lastTimestampRef.current}`)
if (!res.ok) return
const data = await res.json()
if (!Array.isArray(data) || data.length === 0) return
setMessages(prev => {
const updated = [...prev, ...data]
// Deduplicate by id
const seen = new Set()
const deduped = updated.filter(m => {
if (seen.has(m.id)) return false
seen.add(m.id)
return true
})
return deduped.slice(-200)
})
const maxTs = Math.max(...data.map(m => m.timestamp))
if (maxTs > lastTimestampRef.current) lastTimestampRef.current = maxTs
} catch {
// silently ignore network errors
}
}, [])
useEffect(() => {
fetchMessages()
const id = setInterval(fetchMessages, POLL_INTERVAL)
return () => clearInterval(id)
}, [fetchMessages])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const saveName = () => {
const n = nameInput.trim()
if (!n) return
setPlayerName(n)
localStorage.setItem('rolld_chat_name', n)
setEditingName(false)
setTimeout(() => inputRef.current?.focus(), 50)
}
const sendMessage = async () => {
const text = inputText.trim()
if (!text || !playerName || sending) return
setSending(true)
setInputText('')
try {
await fetch(`${SERVER}/chat/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: playerName, text }),
})
// Immediately poll to get our own message back
await fetchMessages()
} catch {
// ignore
} finally {
setSending(false)
inputRef.current?.focus()
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
const formatTime = (ts) => {
const d = new Date(ts)
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
return (
<div className="min-h-screen pt-20 px-4 flex flex-col">
<div className="max-w-2xl mx-auto w-full flex flex-col flex-1" style={{ maxHeight: 'calc(100vh - 5rem)' }}>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-rolld-text">Chat général</h1>
<p className="text-rolld-muted text-sm mt-1">Partagé entre le jeu et le site rafraîchi toutes les 3s</p>
</div>
{/* Name badge */}
{!editingName ? (
<button
onClick={() => { setNameInput(playerName); setEditingName(true) }}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl border border-rolld-border bg-rolld-surface text-sm hover:border-rolld-accent/40 transition-colors"
>
<span className="text-rolld-muted">Joueur :</span>
<span className="font-bold" style={{ color: theme.accentLight }}>{playerName}</span>
<span className="text-rolld-muted text-xs"></span>
</button>
) : (
<div className="flex items-center gap-2">
<input
autoFocus
value={nameInput}
onChange={e => setNameInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveName()}
placeholder="Ton pseudo"
maxLength={24}
className="w-36 px-3 py-1.5 rounded-xl border border-rolld-accent/60 bg-rolld-surface text-rolld-text text-sm outline-none focus:border-rolld-accent"
/>
<button
onClick={saveName}
className="px-3 py-1.5 rounded-xl text-sm font-bold text-white"
style={{ background: theme.accent }}
>
OK
</button>
</div>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto rounded-2xl border border-rolld-border bg-rolld-surface p-4 space-y-1 min-h-0">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-rolld-muted text-sm">
Aucun message pour l'instant. Soyez le premier !
</div>
) : (
messages.map(msg => (
<div key={msg.id} className="flex items-baseline gap-2 py-0.5 group">
<span className="text-rolld-muted text-xs font-mono shrink-0 opacity-60 group-hover:opacity-100 transition-opacity w-11">
{formatTime(msg.timestamp)}
</span>
<span className="text-sm font-bold shrink-0" style={{ color: theme.accentLight }}>
{msg.name}
</span>
<span className="text-sm text-rolld-text break-words min-w-0">
{msg.text}
</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="mt-3 flex gap-2 pb-4">
<input
ref={inputRef}
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={playerName ? 'Écrire un message (Entrée pour envoyer)' : 'Choisissez d\'abord un pseudo →'}
maxLength={200}
disabled={!playerName || editingName}
className="flex-1 px-4 py-3 rounded-xl border border-rolld-border bg-rolld-surface text-rolld-text text-sm outline-none focus:border-rolld-accent/60 disabled:opacity-40 transition-colors"
/>
<button
onClick={sendMessage}
disabled={!inputText.trim() || !playerName || sending || editingName}
className="px-5 py-3 rounded-xl text-sm font-bold text-white transition-all duration-200 hover:scale-105 disabled:opacity-40 disabled:hover:scale-100"
style={{ backgroundImage: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})` }}
>
Envoyer
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useState, useEffect, useCallback } from 'react'
import { theme } from '../env'
const SERVER = 'https://game.rolld.kerboul.me'
const TABS = [
{ key: 'totalDistance', label: 'Distance', unit: 'm', format: v => Math.round(v ?? 0).toLocaleString('fr-FR') },
{ key: 'maxSpeed', label: 'Vitesse max', unit: 'm/s', format: v => (v ?? 0).toFixed(1) },
{ key: 'totalJumps', label: 'Sauts', unit: '', format: v => (v ?? 0).toLocaleString('fr-FR') },
{ key: 'bumpsGiven', label: 'Bumps', unit: '', format: v => (v ?? 0).toLocaleString('fr-FR') },
{ key: 'totalPlaytime',label: 'Temps de jeu',unit: '', format: v => {
const total = Math.round(v ?? 0)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
return h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`
}},
]
export default function StatsPage() {
const [activeTab, setActiveTab] = useState(TABS[0].key)
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(false)
const [lastRefresh, setLastRefresh] = useState(null)
const fetchLeaderboard = useCallback(async (key) => {
setLoading(true)
try {
const res = await fetch(`${SERVER}/stats/leaderboard/${key}`)
if (!res.ok) throw new Error(res.statusText)
const data = await res.json()
setRows(data)
setLastRefresh(new Date())
} catch {
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
setRows([])
fetchLeaderboard(activeTab)
const id = setInterval(() => fetchLeaderboard(activeTab), 30_000)
return () => clearInterval(id)
}, [activeTab, fetchLeaderboard])
const currentTab = TABS.find(t => t.key === activeTab)
const medalColor = (i) => {
if (i === 0) return '#f39c12'
if (i === 1) return '#9b9b9b'
if (i === 2) return '#cd7f32'
return theme.accentLight
}
return (
<div className="min-h-screen pt-20 px-4">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-black text-rolld-text mb-2">Classements</h1>
<p className="text-rolld-muted text-sm">Top 10 joueurs par catégorie mis à jour en temps réel</p>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 justify-center mb-8">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
activeTab === tab.key
? 'text-white'
: 'text-rolld-muted hover:text-rolld-text bg-rolld-surface border border-rolld-border'
}`}
style={activeTab === tab.key ? {
background: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})`,
} : {}}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-2xl overflow-hidden border border-rolld-border bg-rolld-surface">
<div className="px-6 py-4 border-b border-rolld-border flex items-center justify-between">
<span className="text-rolld-text font-semibold">{currentTab.label}</span>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-rolld-muted text-xs">
{lastRefresh.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
<button
onClick={() => fetchLeaderboard(activeTab)}
disabled={loading}
className="text-xs text-rolld-accent hover:text-rolld-accent-light transition-colors disabled:opacity-40"
>
{loading ? '...' : 'Actualiser'}
</button>
</div>
</div>
{loading && rows.length === 0 ? (
<div className="py-16 text-center text-rolld-muted text-sm">Chargement</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-rolld-muted text-sm">Aucune donnée pour l'instant.</div>
) : (
<table className="w-full">
<tbody>
{rows.map((row, i) => (
<tr
key={row.name}
className={`border-b border-rolld-border/50 last:border-0 transition-colors hover:bg-rolld-border/20 ${
i === 0 ? 'bg-rolld-border/10' : ''
}`}
>
<td className="px-6 py-4 w-12">
<span className="text-sm font-bold" style={{ color: medalColor(i) }}>
{i < 3 ? ['🥇', '🥈', '🥉'][i] : `#${i + 1}`}
</span>
</td>
<td className="px-2 py-4 flex-1 text-rolld-text font-medium">
{row.name}
</td>
<td className="px-6 py-4 text-right font-mono text-sm" style={{ color: theme.accentLight }}>
{currentTab.format(row.value)}
{currentTab.unit && <span className="text-rolld-muted ml-1">{currentTab.unit}</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,15 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!134 &13400000
PhysicsMaterial:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Bouncy
serializedVersion: 2
m_DynamicFriction: 0.6
m_StaticFriction: 0.6
m_Bounciness: 0.74
m_FrictionCombine: 0
m_BounceCombine: 0

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 38ed95051af515848a7513429d4f0413
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 13400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,15 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!134 &13400000
PhysicsMaterial:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: GelBleu
serializedVersion: 2
m_DynamicFriction: 0
m_StaticFriction: 0
m_Bounciness: 1
m_FrictionCombine: 1
m_BounceCombine: 3

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 458e6466a22c1204cb2e77d378867d7b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 13400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,15 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!134 &13400000
PhysicsMaterial:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: GelOrange
serializedVersion: 2
m_DynamicFriction: 0.6
m_StaticFriction: 0.6
m_Bounciness: 0.74
m_FrictionCombine: 0
m_BounceCombine: 0

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 61512ca9473715648874e2d1f555c50f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 13400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,15 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!134 &13400000
PhysicsMaterial:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: GelViolet
serializedVersion: 2
m_DynamicFriction: 1
m_StaticFriction: 1
m_Bounciness: 0
m_FrictionCombine: 3
m_BounceCombine: 0

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 86c56232f118b4c4caa7fc9d124fc344
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -8,8 +8,8 @@ Material:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: CheckpointMat
m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0}
m_Parent: {fileID: 0}
m_Shader: {fileID: -6465566751694194690, guid: c52a5eb90c085474582a223ce9475866, type: 3}
m_Parent: {fileID: -876546973899608171, guid: c52a5eb90c085474582a223ce9475866, type: 3}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
@@ -22,63 +22,22 @@ Material:
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_TexEnvs: []
m_Ints: []
m_Floats:
- _BumpScale: 1
- _Cutoff: 0.5
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _GlossMapScale: 1
- _Glossiness: 0.5
- _GlossyReflections: 1
- _Metallic: 0
- _Mode: 0
- _OcclusionStrength: 1
- _Parallax: 0.02
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _UVSec: 0
- _ZWrite: 1
m_Colors:
- _Color: {r: 0.1, g: 0.9, b: 0.3, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
m_Floats: []
m_Colors: []
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &8924139153543123182
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 639247ca83abc874e893eb93af2b5e44, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.ShaderGraph.Editor::UnityEditor.Rendering.BuiltIn.AssetVersion
version: 0

Binary file not shown.

View File

@@ -1,15 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!134 &13400000
PhysicsMaterial:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Normal
serializedVersion: 2
m_DynamicFriction: 0.6
m_StaticFriction: 0.6
m_Bounciness: 0
m_FrictionCombine: 0
m_BounceCombine: 0

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 36e82e5cf5450404999af634c1d3cbbd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 13400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -15,9 +15,15 @@ public class PlayerController : MonoBehaviour
public float JumpForce = 5f; // Force applied when jumping
public float MovementSpeed = 5f; // Speed of player movement
public float MovementSpeed = 25f; // Speed of player movement
public float BoostSpeed = 2f; // Multiplicateur de vitesse sur GelOrange
[Header("Steering Feel")]
[Tooltip("Damps velocity perpendicular to input — higher = sharper turns")]
public float turnDamping = 1.5f;
[Tooltip("Horizontal friction when no input is held")]
public float idleDrag = 0.2f;
[Header("Bump Collision")]
public float bumpForce = 4f; // Impulse force when bumping a remote player
public float bumpCooldown = 0.25f; // Minimum time between bumps from the same player
@@ -38,6 +44,7 @@ public class PlayerController : MonoBehaviour
public float maxVelocity = 120f; // Velocity cap to prevent infinite acceleration
public float respawnY = -10f; // Y threshold for respawn
private Vector3 _spawnPos = new Vector3(0f, 3f, -30f);
private Rigidbody _rb;
// Squash & stretch
private bool _isSquashing = false;
@@ -74,10 +81,12 @@ public class PlayerController : MonoBehaviour
void Start()
{
Debug.Log("PlayerController script initialized.");
// Cursor lock is handled by LobbyUI on connect/disconnect
_meshTransform = transform; // Will be the sphere itself for squash
_rb = GetComponent<Rigidbody>();
_meshTransform = transform;
}
public void SetSpawnPosition(Vector3 pos) => _spawnPos = pos;
/// <summary>
/// Called by LobbyUI after connecting. Sets up the local player
/// with a floating name label and a 50% color tint.
@@ -160,8 +169,7 @@ public class PlayerController : MonoBehaviour
var cam = Camera.main;
if (cam != null)
{
// Billboard locked to Y axis — only rotate around vertical
Vector3 lookDir = _nameLabelObj.transform.position - cam.transform.position;
Vector3 lookDir = cam.transform.position - _nameLabelObj.transform.position;
lookDir.y = 0f;
if (lookDir.sqrMagnitude > 0.001f)
_nameLabelObj.transform.rotation = Quaternion.LookRotation(lookDir);
@@ -172,20 +180,18 @@ public class PlayerController : MonoBehaviour
// Update is called once per frame
void Update()
{
// Toggle cursor lock/unlock avec clic droit (disabled when keybind menu is open)
if (!KeyBindingUI.IsVisible && Mouse.current != null && Mouse.current.rightButton.wasPressedThisFrame)
// Cursor lock: right-click unlocks, left-click re-locks (disabled when any UI panel is open)
if (!ChatUI.IsVisible && !KeyBindingUI.IsVisible && Mouse.current != null)
{
if (Cursor.lockState == CursorLockMode.Locked)
if (Cursor.lockState == CursorLockMode.Locked && Mouse.current.rightButton.wasPressedThisFrame)
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
Debug.Log("Cursor UNLOCKED");
}
else
else if (Cursor.lockState != CursorLockMode.Locked && Mouse.current.leftButton.wasPressedThisFrame)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
Debug.Log("Cursor LOCKED");
}
}
@@ -202,16 +208,15 @@ public class PlayerController : MonoBehaviour
// --- Respawn if fallen off the map ---
if (transform.position.y < respawnY)
{
Rigidbody rbRespawn = GetComponent<Rigidbody>();
if (rbRespawn != null)
if (_rb != null)
{
rbRespawn.linearVelocity = Vector3.zero;
rbRespawn.angularVelocity = Vector3.zero;
_rb.linearVelocity = Vector3.zero;
_rb.angularVelocity = Vector3.zero;
_rb.useGravity = true;
}
transform.position = _spawnPos;
isOnGelViolet = false;
isOnGelOrange = false;
if (rbRespawn != null) rbRespawn.useGravity = true;
Debug.Log("[Player] Respawned after falling.");
return;
}
@@ -221,7 +226,7 @@ public class PlayerController : MonoBehaviour
_fallWarningAlpha = Mathf.Lerp(_fallWarningAlpha, fallTarget, Time.deltaTime * 5f);
// Mouvement continu selon les directions maintenues
Rigidbody rb = GetComponent<Rigidbody>();
Rigidbody rb = _rb;
if (rb != null)
{
float currentSpeed = MovementSpeed;
@@ -261,21 +266,28 @@ public class PlayerController : MonoBehaviour
}
}
if (isForwardHeld)
Vector3 inputDir = Vector3.zero;
if (isForwardHeld) inputDir += forward;
if (isBackwardsHeld) inputDir -= forward;
if (isRightHeld) inputDir += right;
if (isLeftHeld) inputDir -= right;
if (inputDir.sqrMagnitude > 0.01f)
{
rb.AddForce(forward * currentSpeed * Time.deltaTime, ForceMode.VelocityChange);
inputDir.Normalize();
rb.AddForce(inputDir * currentSpeed * Time.deltaTime, ForceMode.VelocityChange);
// Counter-force on the lateral component (makes turns sharper)
Vector3 horizVel = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
Vector3 perp = horizVel - Vector3.Project(horizVel, inputDir);
if (perp.sqrMagnitude > 0.01f)
rb.AddForce(-perp * turnDamping * Time.deltaTime, ForceMode.VelocityChange);
}
if (isBackwardsHeld)
else if (!isOnGelViolet)
{
rb.AddForce(-forward * currentSpeed * Time.deltaTime, ForceMode.VelocityChange);
}
if (isLeftHeld)
{
rb.AddForce(-right * currentSpeed * Time.deltaTime, ForceMode.VelocityChange);
}
if (isRightHeld)
{
rb.AddForce(right * currentSpeed * Time.deltaTime, ForceMode.VelocityChange);
// Gradual horizontal slow-down when no key is held
Vector3 horizVel = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
rb.AddForce(-horizVel * idleDrag * Time.deltaTime, ForceMode.VelocityChange);
}
// GelViolet : colle la balle à la surface (sticky)
@@ -313,42 +325,25 @@ public class PlayerController : MonoBehaviour
void OnCollisionStay(Collision collision)
{
Collider col = collision.collider;
if (col != null && col.sharedMaterial != null)
{
if (col.sharedMaterial.name.Contains("GelOrange"))
{
isOnGelOrange = true;
}
else
{
isOnGelOrange = false;
}
if (col == null || col.sharedMaterial == null) return;
if (col.sharedMaterial.name.Contains("GelViolet"))
if (col.sharedMaterial.name.Contains("GelOrange"))
{
isOnGelOrange = true;
}
if (col.sharedMaterial.name.Contains("GelViolet"))
{
if (!isOnGelViolet)
{
if (!isOnGelViolet)
{
// Premier contact : sauvegarder le drag et augmenter le frein
Rigidbody rb = GetComponent<Rigidbody>();
if (rb != null)
{
originalDrag = rb.linearDamping;
rb.linearDamping = 1f; // Fort frein pour éviter le catapultage aux bords
}
}
isOnGelViolet = true;
// Normale instantanée de la surface de contact
Vector3 avgNormal = Vector3.zero;
foreach (ContactPoint contact in collision.contacts)
{
avgNormal += contact.normal;
}
stickyNormal = avgNormal.normalized;
}
else
{
isOnGelViolet = false;
originalDrag = _rb != null ? _rb.linearDamping : 0f;
if (_rb != null) _rb.linearDamping = 1f;
}
isOnGelViolet = true;
Vector3 avgNormal = Vector3.zero;
foreach (ContactPoint contact in collision.contacts)
avgNormal += contact.normal;
stickyNormal = avgNormal.normalized;
}
}
@@ -377,33 +372,19 @@ public class PlayerController : MonoBehaviour
public void OnJump(InputAction.CallbackContext context)
{
if (ChatUI.IsVisible) { isJumpPressed = false; jumpPressTime = 0f; return; }
if (context.started)
{
// Touche appuyée
isJumpPressed = true;
jumpPressTime = 0f;
Debug.Log("Jump Started");
}
else if (context.performed)
{
// Action validée (utile pour saut immédiat aussi)
Debug.Log("Jump Performed");
StatsTracker.Instance?.RegisterJump();
}
else if (context.canceled)
{
// Touche relâchée
float jumpForceFactor = Mathf.Clamp01(jumpPressTime / maxJumpHoldTime);
if (IsGrounded())
{
PerformJump(jumpForceFactor * JumpForce);
Debug.Log($"Jump Released after {jumpPressTime}s -> Force factor: {jumpForceFactor}");
}
else
{
Debug.Log("Jump Released but not grounded.");
}
// Reset jump state so gauge goes back to 0
isJumpPressed = false;
jumpPressTime = 0f;
}
@@ -411,17 +392,9 @@ public class PlayerController : MonoBehaviour
public void PerformJump(float force)
{
Rigidbody rb = GetComponent<Rigidbody>();
if (rb != null)
{
// Jump direction: surface normal when on sticky, otherwise up
Vector3 jumpDir = isOnGelViolet ? stickyNormal : Vector3.up;
rb.AddForce(jumpDir * force, ForceMode.Impulse);
}
else
{
Debug.LogWarning("Rigidbody component not found on PlayerController.");
}
if (_rb == null) return;
Vector3 jumpDir = isOnGelViolet ? stickyNormal : Vector3.up;
_rb.AddForce(jumpDir * force, ForceMode.Impulse);
}
private bool IsGrounded()
@@ -435,75 +408,30 @@ public class PlayerController : MonoBehaviour
public void OnForward(InputAction.CallbackContext context)
{
if (context.started)
{
isForwardHeld = true;
Debug.Log("Forward Action Started");
}
else if (context.performed)
{
// Forward action performed
Debug.Log("Forward Action Performed");
}
else if (context.canceled)
{
isForwardHeld = false;
Debug.Log("Forward Action Canceled");
}
if (ChatUI.IsVisible) { isForwardHeld = false; return; }
if (context.started) isForwardHeld = true;
else if (context.canceled) isForwardHeld = false;
}
public void OnBackwards(InputAction.CallbackContext context)
{
if (context.started)
{
isBackwardsHeld = true;
Debug.Log("Backwards Action Started");
}
else if (context.performed)
{
Debug.Log("Backwards Action Performed");
}
else if (context.canceled)
{
isBackwardsHeld = false;
Debug.Log("Backwards Action Canceled");
}
if (ChatUI.IsVisible) { isBackwardsHeld = false; return; }
if (context.started) isBackwardsHeld = true;
else if (context.canceled) isBackwardsHeld = false;
}
public void OnLeft(InputAction.CallbackContext context)
{
if (context.started)
{
isLeftHeld = true;
Debug.Log("Left Action Started");
}
else if (context.performed)
{
Debug.Log("Left Action Performed");
}
else if (context.canceled)
{
isLeftHeld = false;
Debug.Log("Left Action Canceled");
}
if (ChatUI.IsVisible) { isLeftHeld = false; return; }
if (context.started) isLeftHeld = true;
else if (context.canceled) isLeftHeld = false;
}
public void OnRight(InputAction.CallbackContext context)
{
if (context.started)
{
isRightHeld = true;
Debug.Log("Right Action Started");
}
else if (context.performed)
{
Debug.Log("Right Action Performed");
}
else if (context.canceled)
{
isRightHeld = false;
Debug.Log("Right Action Canceled");
}
if (ChatUI.IsVisible) { isRightHeld = false; return; }
if (context.started) isRightHeld = true;
else if (context.canceled) isRightHeld = false;
}
// --- Bump collision with remote players ---
@@ -527,17 +455,15 @@ public class PlayerController : MonoBehaviour
return;
_lastBumpTime[id] = Time.time;
StatsTracker.Instance?.RegisterBump();
// Repulsion direction: from remote toward local player
Vector3 dir = (transform.position - other.transform.position).normalized;
// Add slight upward component so the ball lifts off the ground
dir = (dir + Vector3.up * 0.3f).normalized;
var rb = GetComponent<Rigidbody>();
if (rb != null)
{
rb.AddForce(dir * bumpForce, ForceMode.Impulse);
}
if (_rb != null)
_rb.AddForce(dir * bumpForce, ForceMode.Impulse);
}
void OnCollisionEnter(Collision collision)
@@ -577,6 +503,16 @@ public class PlayerController : MonoBehaviour
_isSquashing = false;
}
public void ResetInputs()
{
isForwardHeld = false;
isBackwardsHeld = false;
isLeftHeld = false;
isRightHeld = false;
isJumpPressed = false;
jumpPressTime = 0f;
}
void OnDestroy()
{
// Clean up name label (it's not parented to the ball)
@@ -620,6 +556,13 @@ public class PlayerController : MonoBehaviour
fontStyle = FontStyle.Bold
};
labelStyle.normal.textColor = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.9f);
// Outline: draw 4× in black at ±1px, then once in white
var shadowStyle = new GUIStyle(labelStyle);
shadowStyle.normal.textColor = new Color(0f, 0f, 0f, _gaugeDisplayAlpha * 0.55f);
GUI.Label(new Rect(x + 1f, y - 25f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x - 1f, y - 27f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x + 1f, y - 27f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x - 1f, y - 25f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x, y - 26f, barWidth, 24f), "JUMP POWER", labelStyle);
// Ensure textures
@@ -729,10 +672,9 @@ public class PlayerController : MonoBehaviour
// ========================
// SPEED INDICATOR
// ========================
Rigidbody rbHud = GetComponent<Rigidbody>();
if (rbHud != null)
if (_rb != null)
{
float speed = rbHud.linearVelocity.magnitude;
float speed = _rb.linearVelocity.magnitude;
var speedStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleRight,

View File

@@ -1,38 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-670956545734759409
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b94fcd11afffcb142908bfcb1e261fba, type: 3}
m_Name: MotionBlur
m_EditorClassIdentifier: Unity.Postprocessing.Runtime::UnityEngine.Rendering.PostProcessing.MotionBlur
active: 1
enabled:
overrideState: 1
value: 1
shutterAngle:
overrideState: 0
value: 270
sampleCount:
overrideState: 0
value: 10
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8e6292b2c06870d4495f009f912b9600, type: 3}
m_Name: PostProcessing Profile
m_EditorClassIdentifier: Unity.Postprocessing.Runtime::UnityEngine.Rendering.PostProcessing.PostProcessProfile
settings:
- {fileID: -670956545734759409}

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: fc446179a9ae97a4a8ad5c8aa1c2dd47
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,34 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fcf7219bab7fe46a1ad266029b2fee19, type: 3}
m_Name: Readme
m_EditorClassIdentifier:
icon: {fileID: 2800000, guid: 727a75301c3d24613a3ebcec4a24c2c8, type: 3}
title: URP Empty Template
sections:
- heading: Welcome to the Universal Render Pipeline
text: This template includes the settings and assets you need to start creating with the Universal Render Pipeline.
linkText:
url:
- heading: URP Documentation
text:
linkText: Read more about URP
url: https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@latest
- heading: Forums
text:
linkText: Get answers and support
url: https://forum.unity.com/forums/universal-render-pipeline.383/
- heading: Report bugs
text:
linkText: Submit a report
url: https://unity3d.com/unity/qa/bug-reporting
loadedLayout: 1

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 8105016687592461f977c054a80ce2f2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -10,6 +10,6 @@ PhysicsMaterial:
serializedVersion: 2
m_DynamicFriction: 0.6
m_StaticFriction: 0.6
m_Bounciness: 0
m_Bounciness: 0.2
m_FrictionCombine: 0
m_BounceCombine: 0

File diff suppressed because one or more lines are too long

View File

@@ -1,573 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Builds a compact 80×80 tutorial arena at runtime with connected zones:
///
/// Zone A (South) — Movement basics + jump gaps
/// Zone B (Center-S) — Bounce (GelBleu) training
/// Zone C (West) — Speed (GelOrange) training
/// Zone D (NW) — Moving platforms
/// Zone E (NE) — Sticky walls (GelViolet) training
/// Zone F (North) — Combo challenge (all gels + platforms)
/// Zone G (Center) — Central tower (final challenge)
///
/// Spawn: (0, 2, -30) — south edge
///
/// Ball capabilities reference (from PlayerController):
/// Max jump height ≈ 5.1 units (JumpForce=10, gravity=9.81)
/// Horizontal jump range ≈ 6-8 units at cruising speed
/// GelBleu bounce = perfect elasticity (bounciness 1.0, combine=Max)
/// GelOrange speed = ×3.83 multiplier (~19 u/s)
/// GelViolet sticky = surface-relative movement, jump = surface normal
/// Ball radius = 0.5, min platform 2×2 for playability
///
/// Loads PhysicMaterials from Resources/ folder.
/// Attach to a persistent GameObject (e.g. NetworkManager).
/// </summary>
public class ArenaZoneBuilder : MonoBehaviour
{
private PhysicsMaterial _matGelBleu;
private PhysicsMaterial _matGelOrange;
private PhysicsMaterial _matGelViolet;
private PhysicsMaterial _matBouncy;
private PhysicsMaterial _matNormal;
private Material _baseMat;
private readonly List<MovingPlatform> _movingPlatforms = new List<MovingPlatform>();
// ── Color palette ──
private static readonly Color ColFloor = new Color(0.28f, 0.29f, 0.34f, 1f);
private static readonly Color ColBleu = new Color(0.2f, 0.5f, 1f, 0.85f);
private static readonly Color ColBleuLt = new Color(0.3f, 0.65f, 1f, 0.9f);
private static readonly Color ColOrange = new Color(1f, 0.55f, 0.1f, 0.85f);
private static readonly Color ColOrangeLt = new Color(1f, 0.65f, 0.2f, 0.9f);
private static readonly Color ColViolet = new Color(0.6f, 0.2f, 0.8f, 0.8f);
private static readonly Color ColVioletLt = new Color(0.7f, 0.35f, 0.9f, 0.85f);
private static readonly Color ColNormal = new Color(0.42f, 0.43f, 0.50f, 0.9f);
private static readonly Color ColNormalLt = new Color(0.52f, 0.53f, 0.60f, 0.95f);
private static readonly Color ColDark = new Color(0.22f, 0.22f, 0.28f, 0.95f);
private static readonly Color ColGold = new Color(1f, 0.84f, 0f, 0.95f);
private static readonly Color ColWall = new Color(0.35f, 0.35f, 0.42f, 0.95f);
private static readonly Color ColPath = new Color(0.38f, 0.40f, 0.48f, 0.9f);
private static readonly Color ColSignBleu = new Color(0.15f, 0.4f, 0.9f, 0.95f);
private static readonly Color ColSignOrange= new Color(0.9f, 0.45f, 0.05f, 0.95f);
private static readonly Color ColSignViolet= new Color(0.5f, 0.15f, 0.7f, 0.95f);
private static readonly Color ColSignGrey = new Color(0.5f, 0.5f, 0.55f, 0.95f);
private static readonly Color ColSignGold = new Color(0.9f, 0.75f, 0f, 0.95f);
private static readonly Color ColGuide = new Color(0.5f, 0.5f, 0.6f, 0.5f);
// ── Constants ──
private const float HALF = 40f; // Arena half-size
private const float WALL_H = 12f; // Perimeter wall height
private const float WALL_T = 1f; // Perimeter wall thickness
void Start()
{
LoadMaterials();
if (_baseMat == null) FindBaseMaterial();
BuildFloorAndWalls();
BuildPaths();
BuildZoneA_Movement();
BuildZoneB_Bounce();
BuildZoneC_Speed();
BuildZoneD_MovingPlatforms();
BuildZoneE_StickyWalls();
BuildZoneF_Combo();
BuildZoneG_CentralTower();
Debug.Log("[ArenaZoneBuilder] 80x80 tutorial arena built successfully.");
}
void Update()
{
for (int i = 0; i < _movingPlatforms.Count; i++)
{
var mp = _movingPlatforms[i];
if (mp.go == null) continue;
mp.t += Time.deltaTime * mp.speed;
float ping = Mathf.PingPong(mp.t, 1f);
mp.go.transform.position = Vector3.Lerp(mp.posA, mp.posB, ping);
}
}
void OnDestroy()
{
_movingPlatforms.Clear();
}
// ═══════════════════════════════════════════
// FLOOR, WALLS & PATHS
// ═══════════════════════════════════════════
private void BuildFloorAndWalls()
{
// Main floor
CreateZone("Arena_Floor", new Vector3(0, -0.25f, 0),
new Vector3(HALF * 2f, 0.5f, HALF * 2f), _matNormal, ColFloor);
// Perimeter walls (high to prevent escape)
float h2 = WALL_H / 2f;
float full = HALF * 2f + 2f;
CreateZone("Wall_N", new Vector3(0, h2, HALF), new Vector3(full, WALL_H, WALL_T), _matNormal, ColWall);
CreateZone("Wall_S", new Vector3(0, h2, -HALF), new Vector3(full, WALL_H, WALL_T), _matNormal, ColWall);
CreateZone("Wall_E", new Vector3(HALF, h2, 0), new Vector3(WALL_T, WALL_H, full), _matNormal, ColWall);
CreateZone("Wall_W", new Vector3(-HALF, h2, 0), new Vector3(WALL_T, WALL_H, full), _matNormal, ColWall);
}
/// <summary>Ground-level paths connecting all zones, with directional color markers.</summary>
private void BuildPaths()
{
float pathH = 0.06f;
float pathY = 0.03f;
// Spawn -> Zone A (already at spawn)
// Zone A -> Zone B (south to center-south)
CreateZone("Path_A_B", new Vector3(0, pathY, -22f), new Vector3(3f, pathH, 10f), _matNormal, ColPath);
// Zone B -> Zone G center
CreateZone("Path_B_G", new Vector3(0, pathY, -8f), new Vector3(3f, pathH, 10f), _matNormal, ColPath);
// Zone G -> Zone C (center to west)
CreateZone("Path_G_C", new Vector3(-10f, pathY, 0f), new Vector3(12f, pathH, 3f), _matNormal, ColPath);
// Zone C -> Zone D (west to northwest)
CreateZone("Path_C_D", new Vector3(-28f, pathY, 12f), new Vector3(3f, pathH, 16f), _matNormal, ColPath);
// Zone G -> Zone E (center to northeast)
CreateZone("Path_G_E", new Vector3(12f, pathY, 10f), new Vector3(16f, pathH, 3f), _matNormal, ColPath);
// Zone D/E -> Zone F (north)
CreateZone("Path_D_F", new Vector3(-12f, pathY, 28f), new Vector3(16f, pathH, 3f), _matNormal, ColPath);
CreateZone("Path_E_F", new Vector3(12f, pathY, 28f), new Vector3(16f, pathH, 3f), _matNormal, ColPath);
}
// ═══════════════════════════════════════════
// ZONE A — MOVEMENT BASICS + JUMP GAPS (South)
// Origin: (0, 0, -32)
// ═══════════════════════════════════════════
private void BuildZoneA_Movement()
{
// Sign
CreateSign("Sign_A", new Vector3(-4f, 1.5f, -35f), ColSignGrey);
// Spawn platform (raised)
CreateZone("A_Spawn", new Vector3(0, 0.4f, -32f), new Vector3(8f, 0.8f, 6f), _matNormal, ColNormalLt);
// Straight corridor with slight turns to learn rolling
CreateZone("A_Corr1", new Vector3(0, 0.15f, -27f), new Vector3(4f, 0.3f, 5f), _matNormal, ColNormal);
CreateZone("A_Corr2", new Vector3(3f, 0.15f, -23f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormal);
CreateZone("A_Corr3", new Vector3(0f, 0.15f, -19.5f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormal);
// Low guide walls for the corridor
CreateZone("A_GuideL1", new Vector3(-2.5f, 0.6f, -27f), new Vector3(0.3f, 0.9f, 5f), _matNormal, ColGuide);
CreateZone("A_GuideR1", new Vector3(2.5f, 0.6f, -27f), new Vector3(0.3f, 0.9f, 5f), _matNormal, ColGuide);
// Jump gaps: 3 gaps of increasing difficulty
// Gap 1: 2 units gap
CreateZone("A_Plat1", new Vector3(-3f, 0.15f, -16f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
// (gap of 2 units)
CreateZone("A_Plat2", new Vector3(-3f, 0.15f, -10f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
// Gap 2: 3 units gap
// (gap of 3 units)
CreateZone("A_Plat3", new Vector3(-3f, 0.15f, -3f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
// Gap 3: 4.5 units gap (needs charged jump)
// (gap of 4.5 units)
CreateZone("A_Plat4", new Vector3(-3f, 0.15f, 5f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
// Return ramp to ground
CreateRamp("A_Ramp", new Vector3(-3f, 1.0f, 9f), new Vector3(4f, 0.3f, 5f),
-12f, Vector3.right, _matNormal, ColNormal);
Debug.Log("[ArenaZoneBuilder] Zone A (Movement) built.");
}
// ═══════════════════════════════════════════
// ZONE B — BOUNCE TRAINING (Center-South)
// Origin: (10, 0, -15)
// ═══════════════════════════════════════════
private void BuildZoneB_Bounce()
{
Vector3 o = new Vector3(10f, 0f, -15f);
// Sign
CreateSign("Sign_B", o + new Vector3(-3f, 1.5f, -3f), ColSignBleu);
// Entry pad
CreateZone("B_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
// Bounce pads with targets at increasing heights
// Pad 1 (small) -> target at 3m
CreateZone("B_Pad1", o + new Vector3(0, 0.2f, 5f), new Vector3(3f, 0.25f, 3f), _matGelBleu, ColBleu);
CreateZone("B_Tgt1", o + new Vector3(0, 3f, 9f), new Vector3(4f, 0.4f, 4f), _matNormal, ColNormalLt);
// Pad 2 (medium) -> target at 4.5m
CreateZone("B_Pad2", o + new Vector3(0, 3.2f, 9f), new Vector3(3.5f, 0.25f, 3.5f), _matGelBleu, ColBleuLt);
CreateZone("B_Tgt2", o + new Vector3(4f, 5.5f, 12f), new Vector3(4f, 0.4f, 4f), _matNormal, ColNormalLt);
// Pad 3 on target 2 -> top at 8m
CreateZone("B_Pad3", o + new Vector3(4f, 5.7f, 12f), new Vector3(3f, 0.2f, 3f), _matGelBleu, ColBleu);
CreateZone("B_Tgt3", o + new Vector3(0, 8f, 15f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
// Bounce staircase: fall from 8m -> bounce up further
CreateZone("B_Stair_Bnc", o + new Vector3(-5f, 0.2f, 15f), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleu);
CreateZone("B_Stair_Mid", o + new Vector3(-5f, 5f, 19f), new Vector3(4f, 0.4f, 4f), _matNormal, ColNormalLt);
CreateZone("B_Stair_Top", o + new Vector3(-5f, 8.5f, 23f), new Vector3(5f, 0.4f, 4f), _matNormal, ColNormalLt);
// Return to ground via gentle ramp
CreateRamp("B_Return", o + new Vector3(-5f, 4f, 27f), new Vector3(4f, 0.3f, 8f),
-20f, Vector3.right, _matNormal, ColNormal);
Debug.Log("[ArenaZoneBuilder] Zone B (Bounce) built.");
}
// ═══════════════════════════════════════════
// ZONE C — SPEED TRAINING (West)
// Origin: (-25, 0, -5)
// ═══════════════════════════════════════════
private void BuildZoneC_Speed()
{
Vector3 o = new Vector3(-25f, 0f, -5f);
// Sign
CreateSign("Sign_C", o + new Vector3(8f, 1.5f, -2f), ColSignOrange);
// Entry
CreateZone("C_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
// Speed strip 1 -> short, straight, with ramp launch
CreateZone("C_Strip1", o + new Vector3(0, 0.12f, 5f), new Vector3(4f, 0.15f, 8f), _matGelOrange, ColOrange);
// Guide walls
CreateZone("C_GuideL1", new Vector3(o.x - 2.5f, 0.5f, o.z + 5f), new Vector3(0.3f, 1f, 8f), _matNormal, ColGuide);
CreateZone("C_GuideR1", new Vector3(o.x + 2.5f, 0.5f, o.z + 5f), new Vector3(0.3f, 1f, 8f), _matNormal, ColGuide);
// Ramp at end of strip 1
CreateRamp("C_Ramp1", o + new Vector3(0, 0.8f, 10.5f), new Vector3(4f, 0.25f, 4f),
20f, Vector3.right, _matGelOrange, ColOrangeLt);
// Landing platform (12m ahead, 2m up)
CreateZone("C_Land1", o + new Vector3(0, 2f, 18f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
// Speed strip 2 -> longer with a curve
CreateZone("C_Strip2a", o + new Vector3(0, 2.1f, 22f), new Vector3(4f, 0.15f, 5f), _matGelOrange, ColOrange);
CreateZone("C_Strip2b", o + new Vector3(4f, 2.1f, 26f), new Vector3(5f, 0.15f, 4f), _matGelOrange, ColOrangeLt);
CreateZone("C_Strip2c", o + new Vector3(8f, 2.1f, 30f), new Vector3(4f, 0.15f, 5f), _matGelOrange, ColOrange);
// Guide walls for curve
CreateZone("C_GuideO", o + new Vector3(-2.5f, 2.6f, 22f), new Vector3(0.3f, 1f, 5f), _matNormal, ColGuide);
CreateZone("C_GuideI", o + new Vector3(10.5f, 2.6f, 30f), new Vector3(0.3f, 1f, 5f), _matNormal, ColGuide);
// Final landing with another ramp
CreateRamp("C_Ramp2", o + new Vector3(8f, 3f, 34f), new Vector3(4f, 0.25f, 4f),
25f, Vector3.right, _matGelOrange, ColOrangeLt);
CreateZone("C_Land2", o + new Vector3(8f, 4.5f, 38f), new Vector3(6f, 0.4f, 5f), _matNormal, ColNormalLt);
Debug.Log("[ArenaZoneBuilder] Zone C (Speed) built.");
}
// ═══════════════════════════════════════════
// ZONE D — MOVING PLATFORMS (Northwest)
// Origin: (-28, 0, 18)
// ═══════════════════════════════════════════
private void BuildZoneD_MovingPlatforms()
{
Vector3 o = new Vector3(-28f, 0f, 18f);
// Sign
CreateSign("Sign_D", o + new Vector3(4f, 1.5f, -2f), ColSignGrey);
// Entry platform
CreateZone("D_Entry", o + new Vector3(0, 0.2f, 0), new Vector3(6f, 0.4f, 6f), _matNormal, ColNormalLt);
// 3 horizontal sliders (oscillate along X, spaced along Z, rising)
for (int i = 0; i < 3; i++)
{
Vector3 a = o + new Vector3(-2f, 1.5f + i * 2f, 5f + i * 6f);
Vector3 b = a + new Vector3(8f, 0f, 0f);
var go = CreateZone("D_Slide" + i, a, new Vector3(4f, 0.5f, 4f), _matNormal, ColDark);
AddMovingPlatform(go, a, b, 0.3f + i * 0.1f);
}
// Mid-platform
CreateZone("D_Mid", o + new Vector3(2f, 7f, 20f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
// 2 vertical lifts
for (int i = 0; i < 2; i++)
{
Vector3 a = o + new Vector3(-4f + i * 8f, 7.5f, 24f + i * 5f);
Vector3 b = a + new Vector3(0f, 5f, 0f);
var go = CreateZone("D_Lift" + i, a, new Vector3(4f, 0.5f, 4f), _matNormal, ColDark);
AddMovingPlatform(go, a, b, 0.22f + i * 0.1f);
}
// End platform (high, with view)
CreateZone("D_End", o + new Vector3(2f, 12f, 32f), new Vector3(6f, 0.4f, 6f), _matNormal, ColNormalLt);
// Bouncy descent pad at ground level
CreateZone("D_Desc", o + new Vector3(2f, 0.2f, 32f), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleu);
Debug.Log("[ArenaZoneBuilder] Zone D (Moving Platforms) built.");
}
// ═══════════════════════════════════════════
// ZONE E — STICKY WALLS (Northeast)
// Origin: (24, 0, 15)
// ═══════════════════════════════════════════
private void BuildZoneE_StickyWalls()
{
Vector3 o = new Vector3(24f, 0f, 15f);
// Sign
CreateSign("Sign_E", o + new Vector3(-5f, 1.5f, -2f), ColSignViolet);
// Entry pad
CreateZone("E_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
// === Intro wall: simple vertical climb (4m high) ===
// Wall face pointing -X (ball approaches from the left)
CreateZone("E_Wall1", o + new Vector3(3f, 3f, 3f), new Vector3(0.5f, 6f, 5f), _matGelViolet, ColViolet);
// Platform at top of wall
CreateZone("E_Top1", o + new Vector3(3f, 6.2f, 3f), new Vector3(4f, 0.4f, 5f), _matNormal, ColNormalLt);
// === L-shaped sticky: wall -> turn -> wall ===
// Vertical wall going up (face -Z)
CreateZone("E_Wall2a", o + new Vector3(0, 3.5f, 8f), new Vector3(4f, 7f, 0.5f), _matGelViolet, ColViolet);
// Ceiling connecting to second wall (face down, -Y)
CreateZone("E_Ceil", o + new Vector3(0, 7.1f, 10f), new Vector3(4f, 0.5f, 4f), _matGelViolet, ColVioletLt);
// Second wall going down (face +Z)
CreateZone("E_Wall2b", o + new Vector3(0, 3.5f, 12f), new Vector3(4f, 7f, 0.5f), _matGelViolet, ColViolet);
// Landing after L traverse
CreateZone("E_Land2", o + new Vector3(0, 0.2f, 14f), new Vector3(5f, 0.4f, 4f), _matNormal, ColNormalLt);
// === Vertical tunnel: two sticky walls face-to-face, zigzag up ===
// Left wall
CreateZone("E_Tun_L", o + new Vector3(-3.5f, 6f, 18f), new Vector3(0.5f, 12f, 4f), _matGelViolet, ColViolet);
// Right wall
CreateZone("E_Tun_R", o + new Vector3(3.5f, 6f, 18f), new Vector3(0.5f, 12f, 4f), _matGelViolet, ColVioletLt);
// Small ledges alternating sides to help zigzag
for (int i = 0; i < 4; i++)
{
float side = (i % 2 == 0) ? -2.5f : 2.5f;
float h = 2f + i * 2.8f;
CreateZone("E_Tun_Ledge" + i, o + new Vector3(side, h, 18f),
new Vector3(2f, 0.3f, 3f), _matNormal, ColNormalLt);
}
// Top of tunnel
CreateZone("E_TunTop", o + new Vector3(0, 12.2f, 18f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
Debug.Log("[ArenaZoneBuilder] Zone E (Sticky Walls) built.");
}
// ═══════════════════════════════════════════
// ZONE F — COMBO CHALLENGE (North)
// Origin: (0, 0, 30)
// ═══════════════════════════════════════════
private void BuildZoneF_Combo()
{
Vector3 o = new Vector3(0f, 0f, 30f);
// Sign
CreateSign("Sign_F", o + new Vector3(-5f, 1.5f, -4f), ColSignGold);
// Entry
CreateZone("F_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
// Step 1: Speed strip launch
CreateZone("F_Speed", o + new Vector3(0, 0.12f, 4f), new Vector3(3f, 0.15f, 6f), _matGelOrange, ColOrange);
CreateZone("F_GuidL", o + new Vector3(-2f, 0.5f, 4f), new Vector3(0.3f, 0.7f, 6f), _matNormal, ColGuide);
CreateZone("F_GuidR", o + new Vector3(2f, 0.5f, 4f), new Vector3(0.3f, 0.7f, 6f), _matNormal, ColGuide);
// Step 2: Ramp -> bounce pad
CreateRamp("F_Ramp", o + new Vector3(0, 0.6f, 8.5f), new Vector3(3f, 0.25f, 3f),
22f, Vector3.right, _matGelOrange, ColOrangeLt);
CreateZone("F_BncPad", o + new Vector3(0, 0.2f, 13f), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleu);
// Step 3: High platform with sticky wall leading higher
CreateZone("F_MidPlat", o + new Vector3(0, 4.5f, 17f), new Vector3(5f, 0.4f, 4f), _matNormal, ColNormalLt);
CreateZone("F_StickyWall", o + new Vector3(-3f, 7f, 17f), new Vector3(0.5f, 5f, 4f), _matGelViolet, ColViolet);
// Step 4: Moving platform to final
Vector3 mpA = o + new Vector3(0, 9.5f, 17f);
Vector3 mpB = o + new Vector3(0, 9.5f, 23f);
var mp = CreateZone("F_MovPlat", mpA, new Vector3(4f, 0.5f, 4f), _matNormal, ColDark);
AddMovingPlatform(mp, mpA, mpB, 0.25f);
// Step 5: Gold finish platform
CreateZone("F_Finish", o + new Vector3(0, 10f, 27f), new Vector3(6f, 0.5f, 5f), _matNormal, ColGold);
CreateZone("F_FinBnc", o + new Vector3(0, 10.3f, 27f), new Vector3(3f, 0.2f, 3f), _matGelBleu, ColBleuLt);
Debug.Log("[ArenaZoneBuilder] Zone F (Combo) built.");
}
// ═══════════════════════════════════════════
// ZONE G — CENTRAL TOWER (Center)
// Origin: (0, 0, 5)
// ═══════════════════════════════════════════
private void BuildZoneG_CentralTower()
{
Vector3 o = new Vector3(0f, 0f, 5f);
// Base (accessible from ground)
CreateZone("G_Base", o + new Vector3(0, 0.25f, 0), new Vector3(10f, 0.5f, 10f), _matNormal, ColNormal);
// Access ramp from south
CreateRamp("G_Ramp", o + new Vector3(0, 0.8f, -6f), new Vector3(4f, 0.3f, 5f),
-10f, Vector3.right, _matNormal, ColNormal);
// Level 1: Bounce pad -> L2
CreateZone("G_L1_Bnc", o + new Vector3(0, 0.5f, 0), new Vector3(3.5f, 0.25f, 3.5f), _matGelBleu, ColBleu);
// Level 2 (3.5m): platform + speed strip
CreateZone("G_L2", o + new Vector3(0, 3.5f, 0), new Vector3(8f, 0.4f, 8f), _matNormal, ColNormalLt);
CreateZone("G_L2_Spd", o + new Vector3(0, 3.7f, 0), new Vector3(6f, 0.15f, 2f), _matGelOrange, ColOrange);
CreateRamp("G_L2_Ramp", o + new Vector3(3f, 4.5f, 3f), new Vector3(3f, 0.25f, 3f),
25f, Vector3.right, _matGelOrange, ColOrangeLt);
// Level 3 (7m): platform + moving platform to L4
CreateZone("G_L3", o + new Vector3(0, 7f, 0), new Vector3(7f, 0.4f, 7f), _matNormal, ColNormalLt);
CreateZone("G_L3_Bnc", o + new Vector3(2f, 7.25f, 2f), new Vector3(3f, 0.2f, 3f), _matGelBleu, ColBleuLt);
// Moving platform L3->L4
Vector3 mp3a = o + new Vector3(5f, 8f, 0);
Vector3 mp3b = o + new Vector3(0, 8f, 5f);
var m3 = CreateZone("G_MP3", mp3a, new Vector3(3.5f, 0.5f, 3.5f), _matNormal, ColDark);
AddMovingPlatform(m3, mp3a, mp3b, 0.2f);
// Level 4 (10.5m): platform + sticky wall to L5
CreateZone("G_L4", o + new Vector3(0, 10.5f, 0), new Vector3(7f, 0.4f, 7f), _matNormal, ColNormalLt);
CreateZone("G_L4_Sticky", o + new Vector3(-3.8f, 13f, 0), new Vector3(0.5f, 5f, 5f), _matGelViolet, ColViolet);
// Level 5 — SUMMIT (15.5m): gold platform
CreateZone("G_L5", o + new Vector3(0, 15.5f, 0), new Vector3(8f, 0.5f, 8f), _matNormal, ColGold);
CreateZone("G_L5_Bnc", o + new Vector3(0, 15.85f, 0), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleuLt);
// Orbital moving platform for alternative L3->L4 access
Vector3 orb_a = o + new Vector3(-6f, 9f, 0);
Vector3 orb_b = o + new Vector3(0, 9f, -6f);
var orb = CreateZone("G_Orb", orb_a, new Vector3(3.5f, 0.5f, 3.5f), _matNormal, ColDark);
AddMovingPlatform(orb, orb_a, orb_b, 0.18f);
Debug.Log("[ArenaZoneBuilder] Zone G (Central Tower) built.");
}
// ═══════════════════════════════════════════
// SIGN HELPER (zone entrance markers)
// ═══════════════════════════════════════════
private void CreateSign(string name, Vector3 position, Color color)
{
// Tall thin panel as zone marker
CreateZone(name, position, new Vector3(0.3f, 2.5f, 0.3f), _matNormal, color);
// Colored cap on top
CreateZone(name + "_Cap", position + new Vector3(0, 1.45f, 0), new Vector3(0.8f, 0.4f, 0.8f), _matNormal, color);
}
// ═══════════════════════════════════════════
// MATERIAL LOADING
// ═══════════════════════════════════════════
private void LoadMaterials()
{
_matGelBleu = Resources.Load<PhysicsMaterial>("GelBleu");
_matGelOrange = Resources.Load<PhysicsMaterial>("GelOrange");
_matGelViolet = Resources.Load<PhysicsMaterial>("GelViolet");
_matBouncy = Resources.Load<PhysicsMaterial>("Bouncy");
_matNormal = Resources.Load<PhysicsMaterial>("Normal");
if (_matGelBleu == null) Debug.LogWarning("[ArenaZoneBuilder] GelBleu not found in Resources!");
if (_matGelOrange == null) Debug.LogWarning("[ArenaZoneBuilder] GelOrange not found in Resources!");
if (_matGelViolet == null) Debug.LogWarning("[ArenaZoneBuilder] GelViolet not found in Resources!");
if (_matBouncy == null) Debug.LogWarning("[ArenaZoneBuilder] Bouncy not found in Resources!");
if (_matNormal == null) Debug.LogWarning("[ArenaZoneBuilder] Normal not found in Resources!");
}
private void FindBaseMaterial()
{
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
_baseMat = new Material(shader);
}
// ═══════════════════════════════════════════
// ZONE HELPERS
// ═══════════════════════════════════════════
private GameObject CreateZone(string name, Vector3 position, Vector3 size,
PhysicsMaterial physMat, Color color)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.name = name;
go.transform.position = position;
go.transform.localScale = size;
go.isStatic = true;
var col = go.GetComponent<Collider>();
if (col != null && physMat != null) col.material = physMat;
var rend = go.GetComponent<Renderer>();
if (rend != null)
{
var mat = new Material(_baseMat);
SetMatColor(mat, color);
if (color.a < 1f) SetMatTransparent(mat, color);
rend.material = mat;
}
return go;
}
private GameObject CreateRamp(string name, Vector3 position, Vector3 size,
float angle, Vector3 axis, PhysicsMaterial physMat, Color color)
{
var go = CreateZone(name, position, size, physMat, color);
go.transform.rotation = Quaternion.AngleAxis(angle, axis);
return go;
}
private void AddMovingPlatform(GameObject go, Vector3 posA, Vector3 posB, float speed)
{
go.isStatic = false;
_movingPlatforms.Add(new MovingPlatform { go = go, posA = posA, posB = posB, speed = speed });
}
// ═══════════════════════════════════════════
// MATERIAL HELPERS
// ═══════════════════════════════════════════
private static void SetMatColor(Material mat, Color color)
{
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
if (mat.HasProperty("_Color")) mat.color = color;
}
private static void SetMatTransparent(Material mat, Color color)
{
if (mat.HasProperty("_Surface"))
{
mat.SetFloat("_Surface", 1);
mat.SetFloat("_Blend", 0);
}
mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
mat.DisableKeyword("_SURFACE_TYPE_OPAQUE");
mat.EnableKeyword("_ALPHAPREMULTIPLY_ON");
mat.renderQueue = 3000;
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
if (mat.HasProperty("_Color")) mat.color = color;
}
// ═══════════════════════════════════════════
// MOVING PLATFORM DATA
// ═══════════════════════════════════════════
private class MovingPlatform
{
public GameObject go;
public Vector3 posA;
public Vector3 posB;
public float speed;
public float t;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 31c6f8ef706b51448b461b2b027e2ea8

View File

@@ -29,7 +29,9 @@ public class CameraOrbitKeyboard : MonoBehaviour
{
// On gère la souris nous-mêmes
if (_axisController != null) _axisController.enabled = false;
LockCursor();
// Only lock cursor if no UI panel is open
if (!ChatUI.IsVisible && !KeyBindingUI.IsVisible)
LockCursor();
}
void OnDisable()
@@ -55,16 +57,16 @@ public class CameraOrbitKeyboard : MonoBehaviour
var mouse = Mouse.current;
// Clic droit = toggle lock
if (mouse != null && mouse.rightButton.wasPressedThisFrame)
// Right-click unlocks, left-click re-locks (consistent with PlayerController)
if (!ChatUI.IsVisible && !KeyBindingUI.IsVisible && mouse != null)
{
if (Cursor.lockState == CursorLockMode.Locked)
if (Cursor.lockState == CursorLockMode.Locked && mouse.rightButton.wasPressedThisFrame)
UnlockCursor();
else
else if (Cursor.lockState != CursorLockMode.Locked && mouse.leftButton.wasPressedThisFrame)
LockCursor();
}
if (KeyBindingUI.IsVisible) return;
if (KeyBindingUI.IsVisible || ChatUI.IsVisible) return;
// Souris — seulement quand locked (delta infini, sans accrochage au bord)
if (Cursor.lockState == CursorLockMode.Locked && mouse != null)

View File

@@ -13,7 +13,6 @@ public class GameManager : MonoBehaviour
public GameObject playerRoot;
public SpectatorCamera spectatorCamera;
public GameHUD gameHUD;
public EliminationOverlay eliminationOverlay;
public GamePhase CurrentPhase { get; private set; } = GamePhase.Lobby;
public bool IsLocalEliminated { get; private set; } = false;
@@ -27,32 +26,32 @@ public class GameManager : MonoBehaviour
DontDestroyOnLoad(gameObject);
}
void OnEnable()
void Start()
{
var nm = NetworkManager.Instance;
if (nm == null) return;
nm.OnPhaseChanged += HandlePhaseChanged;
nm.OnPhaseChanged += HandlePhaseChanged;
nm.OnCountdownChanged += HandleCountdownChanged;
nm.OnEliminated += HandleEliminated;
nm.OnQualified += HandleQualified;
nm.OnRoundStart += HandleRoundStart;
nm.OnRoundEnd += HandleRoundEnd;
nm.OnGameEnd += HandleGameEnd;
nm.OnDisconnected += HandleDisconnected;
nm.OnEliminated += HandleEliminated;
nm.OnQualified += HandleQualified;
nm.OnRoundStart += HandleRoundStart;
nm.OnRoundEnd += HandleRoundEnd;
nm.OnGameEnd += HandleGameEnd;
nm.OnDisconnected += HandleDisconnected;
}
void OnDisable()
void OnDestroy()
{
var nm = NetworkManager.Instance;
if (nm == null) return;
nm.OnPhaseChanged -= HandlePhaseChanged;
nm.OnPhaseChanged -= HandlePhaseChanged;
nm.OnCountdownChanged -= HandleCountdownChanged;
nm.OnEliminated -= HandleEliminated;
nm.OnQualified -= HandleQualified;
nm.OnRoundStart -= HandleRoundStart;
nm.OnRoundEnd -= HandleRoundEnd;
nm.OnGameEnd -= HandleGameEnd;
nm.OnDisconnected -= HandleDisconnected;
nm.OnEliminated -= HandleEliminated;
nm.OnQualified -= HandleQualified;
nm.OnRoundStart -= HandleRoundStart;
nm.OnRoundEnd -= HandleRoundEnd;
nm.OnGameEnd -= HandleGameEnd;
nm.OnDisconnected -= HandleDisconnected;
}
// ─── Event Handlers ───────────────────────────────────────────────────
@@ -93,7 +92,6 @@ public class GameManager : MonoBehaviour
{
IsLocalEliminated = true;
TransitionTo(GamePhase.Eliminated);
eliminationOverlay?.ShowEliminated();
}
}
@@ -102,15 +100,15 @@ public class GameManager : MonoBehaviour
if (sessionId == NetworkManager.Instance?.LocalSessionId)
{
TransitionTo(GamePhase.Qualified);
eliminationOverlay?.ShowQualified();
}
}
void HandleRoundStart(int round, string mode)
void HandleRoundStart(int round, string mode, int totalRounds)
{
CurrentRound = round;
CurrentMode = mode;
gameHUD?.SetRoundInfo(round, mode);
gameHUD?.SetTotalRounds(totalRounds);
IsLocalEliminated = false;
}
@@ -121,7 +119,6 @@ public class GameManager : MonoBehaviour
void HandleGameEnd(string winner)
{
eliminationOverlay?.ShowGameEnd(winner);
}
void HandleDisconnected()
@@ -140,8 +137,7 @@ public class GameManager : MonoBehaviour
switch (phase)
{
case GamePhase.Lobby:
SetPlayerActive(false);
SetSpectatorActive(false);
SetPlayerActive(NetworkManager.Instance?.IsConnected ?? false);
gameHUD?.SetPhase("lobby");
break;

View File

@@ -47,7 +47,7 @@ public class KeyBindingUI : MonoBehaviour
void Update()
{
if (Keyboard.current != null && Keyboard.current[Key.F2].wasPressedThisFrame)
if (Keyboard.current != null && Keyboard.current[Key.Tab].wasPressedThisFrame)
{
_visible = !_visible;
IsVisible = _visible;
@@ -132,7 +132,7 @@ public class KeyBindingUI : MonoBehaviour
GUILayout.BeginHorizontal();
if (GUILayout.Button("Réinitialiser tout", ImGuiSkin.Button, GUILayout.Height(32)))
ResetAllBindings();
if (GUILayout.Button("Fermer (F2)", ImGuiSkin.Button, GUILayout.Height(32)))
if (GUILayout.Button("Fermer (Tab)", ImGuiSkin.Button, GUILayout.Height(32)))
{
_visible = false;
CancelRebind();
@@ -146,8 +146,7 @@ public class KeyBindingUI : MonoBehaviour
GUILayout.Label("Appuyez sur une touche pour assigner...", ImGuiSkin.Hint);
}
// F2 hint
GUILayout.Label("F2 — Ouvrir / Fermer ce menu", ImGuiSkin.Footer);
GUILayout.Label("Tab — Ouvrir / Fermer ce menu", ImGuiSkin.Footer);
ImGuiSkin.EndWindow();
}

View File

@@ -29,8 +29,8 @@ public class DebugNetworkUI : MonoBehaviour
_fpsCount = 0;
}
// Toggle detailed panel with F1
if (Keyboard.current != null && Keyboard.current[Key.F1].wasPressedThisFrame)
// Toggle detailed panel with ` (backtick)
if (Keyboard.current != null && Keyboard.current[Key.Backquote].wasPressedThisFrame)
_detailsVisible = !_detailsVisible;
}
@@ -47,7 +47,7 @@ public class DebugNetworkUI : MonoBehaviour
DrawDetailPanel(nm);
// Hint
GUI.Label(new Rect(10, Screen.height - 25, 300, 20), "F1 — Debug details", ImGuiSkin.Footer);
GUI.Label(new Rect(10, Screen.height - 25, 300, 20), "` — Debug details", ImGuiSkin.Footer);
}
// ───────── HUD Strip (always visible) ─────────

View File

@@ -1,106 +1,126 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Lobby UI displayed at scene start. Player enters a name, picks a color,
/// and clicks "Rejoindre" to connect to the arena.
/// Manages the full pre-game → in-game transition:
/// - Hides the Player hierarchy until connected
/// - Activates a spectator camera while in lobby
/// - Teleports the player ball to the server spawn position on join
/// Uses Dear ImGuistyle skin via ImGuiSkin.
/// Lobby UI: character setup + room list side by side.
/// - T to open/close chat, Tab for keybinds (handled elsewhere)
/// - Lists available rooms, lets the player create or join one
/// </summary>
public class LobbyUI : MonoBehaviour
{
[Header("Scene References")]
[Tooltip("The root 'Player' GameObject (contains PlayerSphere + cameras). Will be deactivated until connected.")]
public GameObject playerRoot;
[Tooltip("The spectator camera GameObject (SpectatorCamera component).")]
public SpectatorCamera spectatorCamera;
// Preset colors for selection
private static readonly Color[] PresetColors = new Color[]
private static readonly Color[] PresetColors =
{
new Color(1f, 0.35f, 0.2f), // Orange-red
new Color(0.2f, 0.6f, 1f), // Blue
new Color(0.3f, 1f, 0.4f), // Green
new Color(1f, 0.85f, 0.1f), // Yellow
new Color(0.8f, 0.3f, 1f), // Purple
new Color(1f, 0.5f, 0.7f), // Pink
};
private static readonly string[] ColorNames = new string[]
{
"Rouge", "Bleu", "Vert", "Jaune", "Violet", "Rose"
new Color(1f, 0.35f, 0.2f),
new Color(0.2f, 0.6f, 1f),
new Color(0.3f, 1f, 0.4f),
new Color(1f, 0.85f, 0.1f),
new Color(0.8f, 0.3f, 1f),
new Color(1f, 0.5f, 0.7f),
};
private static readonly string[] ColorNames = { "Rouge", "Bleu", "Vert", "Jaune", "Violet", "Rose" };
// UI state
private bool _lobbyActive = true;
private string _playerName = "";
private int _selectedColorIndex = 0;
private bool _lobbyActive = true;
private string _playerName = "";
private int _selectedColorIndex = 0;
private string _statusMessage = "";
private bool _isConnecting = false;
private bool _isReady = false;
private bool _isConnecting = false;
private bool _isReady = false;
// Cached color preview texture (avoid per-frame leak)
// Room list
private NetworkManager.RoomInfo[] _rooms = new NetworkManager.RoomInfo[0];
private bool _roomsFetching = false;
private float _refreshTimer = 0f;
private const float REFRESH_INTERVAL = 4f;
private Vector2 _roomsScroll;
// Color preview texture
private Texture2D _colorPreviewTex;
private int _lastPreviewColorIndex = -1;
void Start()
{
// Generate a default name
_playerName = "Joueur" + Random.Range(100, 999);
_playerName = PlayerPrefs.GetString("rolld_player_name", "Joueur" + Random.Range(100, 999));
// --- Hide the player hierarchy until connected ---
if (playerRoot != null)
playerRoot.SetActive(false);
// --- Activate spectator camera ---
if (spectatorCamera != null)
{
// Wire the gameplay camera reference so spectator knows what to re-enable
var gameplayCam = playerRoot?.GetComponentInChildren<Camera>(true);
if (gameplayCam != null)
spectatorCamera.gameplayCamera = gameplayCam;
spectatorCamera.Activate();
}
// Subscribe to network events
if (NetworkManager.Instance != null)
var nm = NetworkManager.Instance;
if (nm != null)
{
NetworkManager.Instance.OnConnected += OnConnected;
NetworkManager.Instance.OnDisconnected += OnDisconnected;
nm.OnConnected += OnConnected;
nm.OnDisconnected += OnDisconnected;
nm.OnRoomsRefreshed += OnRoomsRefreshed;
}
RefreshRooms();
}
void OnDestroy()
{
if (NetworkManager.Instance != null)
var nm = NetworkManager.Instance;
if (nm != null)
{
NetworkManager.Instance.OnConnected -= OnConnected;
NetworkManager.Instance.OnDisconnected -= OnDisconnected;
nm.OnConnected -= OnConnected;
nm.OnDisconnected -= OnDisconnected;
nm.OnRoomsRefreshed -= OnRoomsRefreshed;
}
}
void Update()
{
if (!_lobbyActive || _isConnecting) return;
_refreshTimer += Time.deltaTime;
if (_refreshTimer >= REFRESH_INTERVAL)
{
_refreshTimer = 0f;
RefreshRooms();
}
}
private void RefreshRooms()
{
if (_roomsFetching) return;
_roomsFetching = true;
NetworkManager.Instance?.FetchRooms();
}
private void OnRoomsRefreshed(NetworkManager.RoomInfo[] rooms)
{
_rooms = rooms;
_roomsFetching = false;
}
// ─── Network callbacks ────────────────────────────────────────────────
private void OnConnected()
{
_lobbyActive = false;
_lobbyActive = false;
_isConnecting = false;
_statusMessage = "";
CancelInvoke(nameof(ConnectionTimeout));
// --- Activate the player hierarchy ---
if (playerRoot != null)
playerRoot.SetActive(true);
// Teleport player ball to the server-assigned spawn position
var nm = NetworkManager.Instance;
if (nm != null && playerRoot != null)
{
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
if (pc != null)
{
// Get spawn pos from the local player's state in the room
var localState = nm.GetLocalPlayerState();
if (localState != null)
{
@@ -108,41 +128,36 @@ public class LobbyUI : MonoBehaviour
var rb = pc.GetComponent<Rigidbody>();
if (rb != null)
{
rb.linearVelocity = Vector3.zero;
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
rb.position = spawnPos;
}
pc.transform.position = spawnPos;
Debug.Log($"[Lobby] Player teleported to spawn: {spawnPos}");
pc.SetSpawnPosition(spawnPos);
}
pc.enabled = true;
// Setup local player visuals: 50% color tint + floating name label
pc.SetupLocalPlayer(nm.LocalPlayerName, nm.LocalPlayerColor);
}
}
// --- Switch from spectator to gameplay camera ---
if (spectatorCamera != null)
spectatorCamera.Deactivate();
// Unlock cursor for gameplay
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
Cursor.visible = false;
}
private void OnDisconnected()
{
_lobbyActive = true;
_isConnecting = false;
_isReady = false;
_lobbyActive = true;
_isConnecting = false;
_isReady = false;
_statusMessage = "Déconnecté du serveur";
_refreshTimer = REFRESH_INTERVAL; // force immediate refresh
// Show cursor for lobby
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
Cursor.visible = true;
// --- Deactivate the player hierarchy ---
if (playerRoot != null)
{
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
@@ -150,188 +165,267 @@ public class LobbyUI : MonoBehaviour
playerRoot.SetActive(false);
}
// --- Re-enable spectator camera ---
if (spectatorCamera != null)
spectatorCamera.Activate();
}
// ─── OnGUI ────────────────────────────────────────────────────────────
void OnGUI()
{
if (!_lobbyActive) return;
ImGuiSkin.EnsureReady();
if (Cursor.lockState != CursorLockMode.None)
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
Cursor.visible = true;
}
ImGuiSkin.DrawOverlay();
bool isConnected = NetworkManager.Instance != null && NetworkManager.Instance.IsConnected;
if (!isConnected)
DrawSetupAndRoomList();
else
DrawWaitingRoom();
}
// ─── Setup + room list ────────────────────────────────────────────────
private void DrawSetupAndRoomList()
{
const float W = 620f, H = 520f;
float x = (Screen.width - W) * 0.5f;
float y = (Screen.height - H) * 0.5f;
ImGuiSkin.BeginWindowAt(x, y, W, H, "ROLL'D");
GUILayout.Label("Choisir une salle et configurer son personnage", ImGuiSkin.WindowSubtitle);
GUILayout.Space(10);
GUILayout.BeginHorizontal();
// ── Left column : character setup ─────────────────────────────
GUILayout.BeginVertical(GUILayout.Width(240));
ImGuiSkin.DrawSectionHeader("PERSONNAGE");
GUILayout.Space(4);
_playerName = GUILayout.TextField(_playerName, 16, ImGuiSkin.TextField, GUILayout.Height(30));
GUILayout.Space(10);
ImGuiSkin.DrawSectionHeader("COULEUR");
GUILayout.Space(4);
GUILayout.BeginHorizontal();
for (int i = 0; i < PresetColors.Length; i++)
{
// ── Pre-connect panel ────────────────────────────────────────
float panelWidth = 420;
float panelHeight = 440;
ImGuiSkin.BeginWindow(panelWidth, panelHeight, "ROLL'D");
Color c = PresetColors[i];
bool selected = _selectedColorIndex == i;
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = selected ? c : c * 0.6f;
var btnStyle = new GUIStyle(ImGuiSkin.ButtonSmall)
{ fontStyle = selected ? FontStyle.Bold : FontStyle.Normal };
if (selected) btnStyle.normal.textColor = Color.white;
if (GUILayout.Button(selected ? $"▸{ColorNames[i][0]}" : $"{ColorNames[i][0]}",
btnStyle, GUILayout.Height(30), GUILayout.Width(34)))
_selectedColorIndex = i;
GUI.backgroundColor = prevBg;
}
GUILayout.EndHorizontal();
GUILayout.Label("Rejoindre l'arène multijoueur", ImGuiSkin.WindowSubtitle);
GUILayout.Space(16);
ImGuiSkin.DrawSectionHeader("PSEUDO");
GUILayout.Space(4);
_playerName = GUILayout.TextField(_playerName, 16, ImGuiSkin.TextField, GUILayout.Height(30));
GUILayout.Space(12);
ImGuiSkin.DrawSectionHeader("COULEUR");
GUILayout.Space(6);
GUILayout.BeginHorizontal();
for (int i = 0; i < PresetColors.Length; i++)
GUILayout.Space(4);
// Color swatch
if (_colorPreviewTex == null || _lastPreviewColorIndex != _selectedColorIndex)
{
if (_colorPreviewTex == null)
{
Color c = PresetColors[i];
bool selected = _selectedColorIndex == i;
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = selected ? c : c * 0.7f;
GUIStyle btnStyle = new GUIStyle(ImGuiSkin.ButtonSmall)
{
fontStyle = selected ? FontStyle.Bold : FontStyle.Normal,
};
if (selected) btnStyle.normal.textColor = Color.white;
string label = selected ? $"▸ {ColorNames[i]}" : ColorNames[i];
if (GUILayout.Button(label, btnStyle, GUILayout.Height(32), GUILayout.Width(60)))
_selectedColorIndex = i;
GUI.backgroundColor = prevBg;
_colorPreviewTex = new Texture2D(1, 1, TextureFormat.RGBA32, false);
_colorPreviewTex.hideFlags = HideFlags.HideAndDontSave;
}
GUILayout.EndHorizontal();
GUILayout.Space(4);
_colorPreviewTex.SetPixel(0, 0, PresetColors[_selectedColorIndex]);
_colorPreviewTex.Apply();
_lastPreviewColorIndex = _selectedColorIndex;
}
var swatchStyle = new GUIStyle(ImGuiSkin.LabelDim) { alignment = TextAnchor.MiddleLeft, fontSize = 11 };
GUILayout.Label($"▌ {ColorNames[_selectedColorIndex]}", swatchStyle);
if (_colorPreviewTex == null || _lastPreviewColorIndex != _selectedColorIndex)
{
if (_colorPreviewTex == null)
{
_colorPreviewTex = new Texture2D(1, 1, TextureFormat.RGBA32, false);
_colorPreviewTex.hideFlags = HideFlags.HideAndDontSave;
}
_colorPreviewTex.SetPixel(0, 0, PresetColors[_selectedColorIndex]);
_colorPreviewTex.Apply();
_lastPreviewColorIndex = _selectedColorIndex;
}
GUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
// Create room button
GUI.enabled = !_isConnecting && !string.IsNullOrWhiteSpace(_playerName);
if (GUILayout.Button("+ Créer une salle", ImGuiSkin.Button, GUILayout.Height(36)))
DoCreate();
GUILayout.Space(4);
// Join any (join or create fallback)
if (GUILayout.Button("▶ Rejoindre n'importe", ImGuiSkin.ButtonAccent, GUILayout.Height(36)))
DoJoinAny();
GUI.enabled = true;
GUILayout.EndVertical();
GUILayout.Space(12);
// ── Right column : room list ───────────────────────────────────
GUILayout.BeginVertical();
GUILayout.BeginHorizontal();
ImGuiSkin.DrawSectionHeader("SALLES DISPONIBLES");
GUILayout.FlexibleSpace();
GUI.enabled = !_roomsFetching;
if (GUILayout.Button(_roomsFetching ? "…" : "↻", ImGuiSkin.ButtonSmall,
GUILayout.Width(28), GUILayout.Height(22)))
{
_refreshTimer = 0f;
RefreshRooms();
}
GUI.enabled = true;
GUILayout.EndHorizontal();
GUILayout.Space(4);
float listH = H - 160f;
_roomsScroll = GUILayout.BeginScrollView(_roomsScroll, ImGuiSkin.ScrollView,
GUILayout.Height(listH));
if (_rooms.Length == 0)
{
var emptyStyle = new GUIStyle(ImGuiSkin.LabelDim) { alignment = TextAnchor.MiddleCenter };
GUILayout.FlexibleSpace();
GUILayout.Box(_colorPreviewTex, GUIStyle.none, GUILayout.Width(80), GUILayout.Height(16));
GUILayout.Label(_roomsFetching ? "Chargement…" : "Aucune salle ouverte.", emptyStyle);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Space(16);
GUI.enabled = !_isConnecting && !string.IsNullOrWhiteSpace(_playerName);
string buttonText = _isConnecting ? "Connexion..." : "▶ Rejoindre l'arène";
if (GUILayout.Button(buttonText, ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
JoinArena();
GUI.enabled = true;
GUILayout.Space(8);
if (!string.IsNullOrEmpty(_statusMessage))
{
bool isError = _statusMessage.Contains("Erreur") || _statusMessage.Contains("Déconnecté");
GUIStyle statusStyle = isError ? ImGuiSkin.StatusRed : new GUIStyle(ImGuiSkin.Hint);
if (!isError) statusStyle.normal.textColor = ImGuiSkin.ColYellow;
GUILayout.Label(_statusMessage, statusStyle);
}
ImGuiSkin.EndWindow();
}
else
{
// ── Waiting room panel (connected, waiting for game to start) ──
float panelWidth = 380;
float panelHeight = 320;
ImGuiSkin.BeginWindow(panelWidth, panelHeight, "SALLE D'ATTENTE");
GUILayout.Label("En attente des joueurs...", ImGuiSkin.WindowSubtitle);
GUILayout.Space(12);
// Player list
ImGuiSkin.DrawSectionHeader("JOUEURS CONNECTÉS");
GUILayout.Space(4);
var nm = NetworkManager.Instance;
if (nm != null && nm.IsConnected)
foreach (var room in _rooms)
{
// We can't directly iterate NetworkState.players from here easily,
// so show basic count
var style = new GUIStyle(GUI.skin.label) { fontSize = 13 };
style.normal.textColor = new Color(0.75f, 0.75f, 0.85f);
GUILayout.Label($" {nm.PlayerCount} joueur(s) dans la salle", style);
}
GUILayout.Space(16);
string roomName = room.metadata?.name ?? ("Salle #" + room.roomId.Substring(0, 6));
int clients = room.clients;
int maxCli = room.maxClients;
// Ready button
if (!_isReady)
{
if (GUILayout.Button("✔ Je suis prêt !", ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
{
_isReady = true;
NetworkManager.Instance?.SendReady();
}
}
else
{
var readyStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 16,
fontStyle = FontStyle.Bold,
};
readyStyle.normal.textColor = new Color(0.3f, 1f, 0.5f);
GUILayout.Label("✔ Prêt ! En attente des autres...", readyStyle, GUILayout.Height(44));
}
GUILayout.BeginHorizontal();
GUILayout.Space(8);
var hintStyle = new GUIStyle(ImGuiSkin.Hint);
hintStyle.normal.textColor = new Color(0.5f, 0.5f, 0.6f);
GUILayout.Label("La partie démarre quand tout le monde est prêt\nou automatiquement après 30 secondes.", hintStyle);
var nameStyle = new GUIStyle(ImGuiSkin.LabelBold) { fontSize = 12 };
GUILayout.Label(roomName, nameStyle, GUILayout.Width(140));
ImGuiSkin.EndWindow();
var countStyle = new GUIStyle(ImGuiSkin.LabelDim) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
GUILayout.Label($"{clients} / {maxCli}", countStyle, GUILayout.Width(48));
GUILayout.FlexibleSpace();
bool full = clients >= maxCli;
GUI.enabled = !_isConnecting && !full && !string.IsNullOrWhiteSpace(_playerName);
if (GUILayout.Button(full ? "Pleine" : "▶ Rejoindre",
ImGuiSkin.ButtonSmall, GUILayout.Width(90), GUILayout.Height(26)))
DoJoinRoom(room.roomId);
GUI.enabled = true;
GUILayout.EndHorizontal();
ImGuiSkin.Separator();
GUILayout.Space(2);
}
}
GUILayout.EndScrollView();
GUILayout.EndVertical();
GUILayout.EndHorizontal(); // end columns
// ── Status bar ────────────────────────────────────────────────
GUILayout.Space(4);
if (!string.IsNullOrEmpty(_statusMessage))
{
bool isError = _statusMessage.Contains("Erreur") || _statusMessage.Contains("Déconnecté");
GUILayout.Label(_statusMessage, isError ? ImGuiSkin.StatusRed : ImGuiSkin.Hint);
}
ImGuiSkin.EndWindow();
}
private void JoinArena()
// ─── Waiting room ─────────────────────────────────────────────────────
private void DrawWaitingRoom()
{
if (NetworkManager.Instance == null)
ImGuiSkin.BeginWindow(400f, 300f, "SALLE D'ATTENTE");
var nm = NetworkManager.Instance;
string roomDisplay = nm != null ? ("Salle #" + nm.RoomId.Substring(0, Mathf.Min(6, nm.RoomId.Length))) : "—";
GUILayout.Label(roomDisplay, ImGuiSkin.WindowSubtitle);
GUILayout.Space(12);
ImGuiSkin.DrawSectionHeader("JOUEURS CONNECTÉS");
GUILayout.Space(4);
if (nm != null)
{
_statusMessage = "Erreur : NetworkManager introuvable";
return;
var s = new GUIStyle(GUI.skin.label) { fontSize = 13 };
s.normal.textColor = new Color(0.75f, 0.75f, 0.85f);
GUILayout.Label($" {nm.PlayerCount} joueur(s) dans la salle", s);
}
GUILayout.Space(16);
if (string.IsNullOrWhiteSpace(_playerName))
if (!_isReady)
{
_statusMessage = "Entrez un pseudo";
return;
}
_isConnecting = true;
_statusMessage = "Connexion au serveur...";
Color selectedColor = PresetColors[_selectedColorIndex];
NetworkManager.Instance.JoinArena(_playerName.Trim(), selectedColor);
// Monitor for errors after a delay
Invoke(nameof(CheckConnectionTimeout), 10f);
}
private void CheckConnectionTimeout()
{
if (_isConnecting && !NetworkManager.Instance.IsConnected)
{
_isConnecting = false;
_statusMessage = "Erreur : Impossible de joindre rolld.io. Réessayez dans quelques instants.";
if (!string.IsNullOrEmpty(NetworkManager.Instance.LastError))
if (GUILayout.Button("✔ Je suis prêt !", ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
{
_statusMessage += $"\n{NetworkManager.Instance.LastError}";
_isReady = true;
NetworkManager.Instance?.SendReady();
}
}
else
{
var rs = new GUIStyle(GUI.skin.label)
{ alignment = TextAnchor.MiddleCenter, fontSize = 16, fontStyle = FontStyle.Bold };
rs.normal.textColor = new Color(0.3f, 1f, 0.5f);
GUILayout.Label("✔ Prêt ! En attente des autres…", rs, GUILayout.Height(44));
}
GUILayout.Space(8);
GUILayout.Label("La partie démarre quand tout le monde est prêt\nou automatiquement après 30 secondes.", ImGuiSkin.Hint);
ImGuiSkin.EndWindow();
}
// ─── Actions ──────────────────────────────────────────────────────────
private string ValidateName()
{
string n = _playerName.Trim();
if (string.IsNullOrEmpty(n)) { _statusMessage = "Entre un pseudo d'abord."; return null; }
return n;
}
private void DoJoinRoom(string roomId)
{
string n = ValidateName(); if (n == null) return;
_isConnecting = true;
_statusMessage = "Connexion à la salle…";
NetworkManager.Instance?.JoinByRoomId(roomId, n, PresetColors[_selectedColorIndex]);
Invoke(nameof(ConnectionTimeout), 10f);
}
private void DoCreate()
{
string n = ValidateName(); if (n == null) return;
_isConnecting = true;
_statusMessage = "Création d'une salle…";
NetworkManager.Instance?.CreateRoom(n, PresetColors[_selectedColorIndex]);
Invoke(nameof(ConnectionTimeout), 10f);
}
private void DoJoinAny()
{
string n = ValidateName(); if (n == null) return;
_isConnecting = true;
_statusMessage = "Connexion…";
NetworkManager.Instance?.JoinArena(n, PresetColors[_selectedColorIndex]);
Invoke(nameof(ConnectionTimeout), 10f);
}
private void ConnectionTimeout()
{
if (!_isConnecting) return;
_isConnecting = false;
var nm = NetworkManager.Instance;
_statusMessage = "Impossible de se connecter. Réessaie.";
if (nm != null && !string.IsNullOrEmpty(nm.LastError))
_statusMessage += $"\n{nm.LastError}";
}
}

View File

@@ -1,6 +1,8 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using Colyseus;
using Colyseus.Schema;
@@ -33,6 +35,24 @@ public class NetworkManager : MonoBehaviour
public string LocalPlayerName { get; private set; } = "";
public Color LocalPlayerColor { get; private set; } = Color.white;
// --- Room listing ---
[System.Serializable] public class RoomMeta { public string name; }
[System.Serializable] public class RoomInfo { public string roomId; public int clients; public int maxClients; public RoomMeta metadata; }
[System.Serializable] private class RoomListWrapper { public List<RoomInfo> items; }
public event Action<RoomInfo[]> OnRoomsRefreshed;
public void FetchRooms() => StartCoroutine(DoFetchRooms());
private IEnumerator DoFetchRooms()
{
using var req = UnityWebRequest.Get($"{serverURL.Replace("wss://", "https://").Replace("ws://", "http://")}/rooms");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { OnRoomsRefreshed?.Invoke(Array.Empty<RoomInfo>()); yield break; }
var wrapper = JsonUtility.FromJson<RoomListWrapper>($"{{\"items\":{req.downloadHandler.text}}}");
OnRoomsRefreshed?.Invoke(wrapper?.items?.ToArray() ?? Array.Empty<RoomInfo>());
}
// --- Events ---
public event Action OnConnected;
public event Action OnDisconnected;
@@ -44,10 +64,9 @@ public class NetworkManager : MonoBehaviour
public event Action<float> OnCountdownChanged; // seconds remaining
public event Action<string, string> OnEliminated; // sessionId, reason
public event Action<string> OnQualified; // sessionId
public event Action<int, string> OnRoundStart; // roundNumber, mode
public event Action<int, string, int> OnRoundStart; // roundNumber, mode, totalRounds
public event Action<int> OnRoundEnd; // roundNumber
public event Action<string> OnGameEnd; // winnerName
public event Action<float> OnDeathZoneYChanged; // for survival mode
// --- Internals ---
private Client _client;
@@ -101,93 +120,109 @@ public class NetworkManager : MonoBehaviour
// ─── Join / Leave ────────────────────────────────────────────────────
public async void JoinArena(string playerName, Color color)
{
if (_isJoining || IsConnected)
{
Debug.LogWarning("[Network] Already connecting or connected.");
return;
}
// ─── Join helpers ─────────────────────────────────────────────────────
private Dictionary<string, object> BuildJoinOptions(string playerName, Color color) => new()
{
{ "name", playerName },
{ "colorR", color.r },
{ "colorG", color.g },
{ "colorB", color.b },
};
private void PrepareJoin(string playerName, Color color)
{
_isJoining = true;
ConnectionStatus = "Connexion en cours...";
LastError = "";
LocalPlayerName = playerName;
LocalPlayerColor = color;
PlayerPrefs.SetString("rolld_player_name", playerName);
_client = new Client(serverURL);
}
private void FinishJoin()
{
LocalSessionId = _room.SessionId;
RoomId = _room.RoomId;
IsConnected = true;
ConnectionStatus = "Connecté";
Debug.Log($"[Network] Joined room {RoomId} as {LocalSessionId}");
_callbacks = Callbacks.Get(_room);
_callbacks.OnAdd(state => state.players, (key, player) => OnPlayerAdd(key, player));
_callbacks.OnRemove(state => state.players, (key, player) => OnPlayerRemove(key, player));
_callbacks.Listen(state => state.phase, (v, _) => _OnPhaseChanged(v));
_callbacks.Listen(state => state.countdown, (v, _) => OnCountdownChanged?.Invoke(v));
_callbacks.Listen(state => state.playersAlive, (v, _) => GameHUD.Instance?.SetPlayersAlive(v));
_room.OnMessage<EliminatedMsg>("eliminated", msg => { OnEliminated?.Invoke(msg.sessionId, msg.reason); });
_room.OnMessage<QualifiedMsg> ("qualified", msg => { OnQualified?.Invoke(msg.sessionId); });
_room.OnMessage<RoundStartMsg>("roundStart", msg => { OnRoundStart?.Invoke(msg.round, msg.mode, msg.totalRounds); });
_room.OnMessage<RoundEndMsg> ("roundEnd", msg => { OnRoundEnd?.Invoke(msg.round); });
_room.OnMessage<GameEndMsg> ("gameEnd", msg => { OnGameEnd?.Invoke(msg.winner); });
_room.OnMessage<ChatUI.ChatMessage>("chat", msg => { ChatUI.Instance?.ReceiveChatMessage(msg); });
_room.OnLeave += OnRoomLeave;
// Seed players already present in the room (state decoded before callbacks were registered)
if (_room.State.players != null)
{
foreach (var kvp in _room.State.players)
OnPlayerAdd(kvp.Key, kvp.Value);
}
OnConnected?.Invoke();
}
private void HandleJoinError(Exception e)
{
Debug.LogError($"[Network] Failed to join: {e.Message}");
ConnectionStatus = "Erreur de connexion";
LastError = e.Message;
IsConnected = false;
}
// ─── Public join methods ──────────────────────────────────────────────
public async void JoinArena(string playerName, Color color)
{
if (_isJoining || IsConnected) return;
PrepareJoin(playerName, color);
try
{
Debug.Log($"[Network] Connecting to {serverURL}...");
_client = new Client(serverURL);
var options = new Dictionary<string, object>
{
{ "name", playerName },
{ "colorR", color.r },
{ "colorG", color.g },
{ "colorB", color.b }
};
_room = await _client.JoinOrCreate<NetworkState>("arena", options);
LocalSessionId = _room.SessionId;
RoomId = _room.RoomId;
IsConnected = true;
ConnectionStatus = "Connecté";
Debug.Log($"[Network] Joined room {RoomId} as {LocalSessionId}");
_callbacks = Callbacks.Get(_room);
// Players
_callbacks.OnAdd(state => state.players, (key, player) => OnPlayerAdd(key, player));
_callbacks.OnRemove(state => state.players, (key, player) => OnPlayerRemove(key, player));
// Game state changes
_callbacks.Listen(state => state.phase, (newValue, prevValue) => _OnPhaseChanged(newValue));
_callbacks.Listen(state => state.countdown, (newValue, prevValue) => OnCountdownChanged?.Invoke(newValue));
_callbacks.Listen(state => state.deathZoneY, (newValue, prevValue) => OnDeathZoneYChanged?.Invoke(newValue));
// Server messages
_room.OnMessage<EliminatedMsg>("eliminated", msg =>
{
Debug.Log($"[Network] Eliminated: {msg.sessionId} ({msg.reason})");
OnEliminated?.Invoke(msg.sessionId, msg.reason);
});
_room.OnMessage<QualifiedMsg>("qualified", msg =>
{
Debug.Log($"[Network] Qualified: {msg.sessionId}");
OnQualified?.Invoke(msg.sessionId);
});
_room.OnMessage<RoundStartMsg>("roundStart", msg =>
{
Debug.Log($"[Network] Round {msg.round} started ({msg.mode})");
OnRoundStart?.Invoke(msg.round, msg.mode);
});
_room.OnMessage<RoundEndMsg>("roundEnd", msg =>
{
Debug.Log($"[Network] Round {msg.round} ended");
OnRoundEnd?.Invoke(msg.round);
});
_room.OnMessage<GameEndMsg>("gameEnd", msg =>
{
Debug.Log($"[Network] Game over — Winner: {msg.winner}");
OnGameEnd?.Invoke(msg.winner);
});
_room.OnLeave += OnRoomLeave;
OnConnected?.Invoke();
_room = await _client.JoinOrCreate<NetworkState>("arena", BuildJoinOptions(playerName, color));
FinishJoin();
}
catch (Exception e)
catch (Exception e) { HandleJoinError(e); }
finally { _isJoining = false; }
}
public async void JoinByRoomId(string roomId, string playerName, Color color)
{
if (_isJoining || IsConnected) return;
PrepareJoin(playerName, color);
try
{
Debug.LogError($"[Network] Failed to join: {e.Message}");
ConnectionStatus = "Erreur de connexion";
LastError = e.Message;
IsConnected = false;
_room = await _client.JoinById<NetworkState>(roomId, BuildJoinOptions(playerName, color));
FinishJoin();
}
finally
catch (Exception e) { HandleJoinError(e); }
finally { _isJoining = false; }
}
public async void CreateRoom(string playerName, Color color, string roomName = null)
{
if (_isJoining || IsConnected) return;
PrepareJoin(playerName, color);
try
{
_isJoining = false;
var opts = BuildJoinOptions(playerName, color);
if (roomName != null) opts["roomName"] = roomName;
_room = await _client.Create<NetworkState>("arena", opts);
FinishJoin();
}
catch (Exception e) { HandleJoinError(e); }
finally { _isJoining = false; }
}
public async void LeaveRoom()
@@ -208,16 +243,10 @@ public class NetworkManager : MonoBehaviour
await _room.Send("checkpointReached", new { index });
}
public async void SendDeathZoneHit()
public async void SendChatMessage(string text)
{
if (_room != null && IsConnected)
await _room.Send("deathZoneHit", null);
}
public async void SendInZone(bool inZone)
{
if (_room != null && IsConnected)
await _room.Send("inZone", new { inZone });
await _room.Send("chat", new { text });
}
// ─── State Callbacks ─────────────────────────────────────────────────
@@ -236,11 +265,14 @@ public class NetworkManager : MonoBehaviour
PlayerCount = _room.State.players?.Count ?? 0;
if (sessionId == LocalSessionId) return;
if (_remotePlayers.ContainsKey(sessionId)) return; // prevent duplicate spawn
if (remotePlayerPrefab != null)
{
Vector3 spawnPos = new Vector3(player.x, player.y, player.z);
GameObject remoteBall = Instantiate(remotePlayerPrefab, spawnPos, Quaternion.identity);
GameObject remoteBall = remotePlayerPrefab != null
? Instantiate(remotePlayerPrefab, spawnPos, Quaternion.identity)
: GameObject.CreatePrimitive(PrimitiveType.Sphere);
remoteBall.transform.position = spawnPos;
remoteBall.name = $"RemotePlayer_{player.name}_{sessionId[..6]}";
var controller = remoteBall.GetComponent<RemotePlayerController>()
@@ -285,11 +317,6 @@ public class NetworkManager : MonoBehaviour
new Vector3(player.avx, player.avy, player.avz)
);
// Sync team color changes (for teams mode)
controller.UpdateTeamColor(player.team,
new Color(player.colorR, player.colorG, player.colorB));
// Hide/show eliminated remote players
controller.SetVisible(!player.isEliminated);
}
}
@@ -342,8 +369,8 @@ public class NetworkManager : MonoBehaviour
private void OnRoomLeave(int code)
{
Debug.Log($"[Network] Left room (code: {code})");
OnDisconnected?.Invoke(); // before Cleanup so listeners still have LocalPlayerName
Cleanup();
OnDisconnected?.Invoke();
}
private void Cleanup()

View File

@@ -1,48 +1,57 @@
using Colyseus.Schema;
// Generated from @colyseus/schema 4.0.15 — DO NOT EDIT MANUALLY
// Class names kept as NetworkPlayer/NetworkState to match existing codebase references.
using Colyseus.Schema;
#if UNITY_5_3_OR_NEWER
using UnityEngine.Scripting;
#endif
// Must match server-side defineTypes field order exactly
public partial class NetworkPlayer : Schema
{
[Type(0, "int32")] public int userId = 0;
[Type(1, "float32")] public float x = 0;
[Type(2, "float32")] public float y = 5;
[Type(3, "float32")] public float z = 0;
[Type(4, "float32")] public float vx = 0;
[Type(5, "float32")] public float vy = 0;
[Type(6, "float32")] public float vz = 0;
[Type(7, "float32")] public float rx = 0;
[Type(8, "float32")] public float ry = 0;
[Type(9, "float32")] public float rz = 0;
[Type(10, "float32")] public float rw = 1;
[Type(11, "float64")] public double t = 0;
[Type(12, "string")] public string name = "";
[Type(13, "float32")] public float colorR = 1;
[Type(14, "float32")] public float colorG = 1;
[Type(15, "float32")] public float colorB = 1;
[Type(16, "float32")] public float avx = 0;
[Type(17, "float32")] public float avy = 0;
[Type(18, "float32")] public float avz = 0;
// Game state
[Type(19, "boolean")] public bool isEliminated = false;
[Type(20, "boolean")] public bool isQualified = false;
[Type(21, "int8")] public int team = 0;
[Type(22, "int8")] public int checkpointIndex = 0;
[Type(23, "boolean")] public bool isReady = false;
#if UNITY_5_3_OR_NEWER
[Preserve]
#endif
public NetworkPlayer() { }
[Type(0, "float32")] public float x = 0;
[Type(1, "float32")] public float y = 5;
[Type(2, "float32")] public float z = 0;
[Type(3, "float32")] public float vx = 0;
[Type(4, "float32")] public float vy = 0;
[Type(5, "float32")] public float vz = 0;
[Type(6, "float32")] public float rx = 0;
[Type(7, "float32")] public float ry = 0;
[Type(8, "float32")] public float rz = 0;
[Type(9, "float32")] public float rw = 1;
[Type(10, "float64")] public double t = 0;
[Type(11, "string")] public string name = "";
[Type(12, "float32")] public float colorR = 1;
[Type(13, "float32")] public float colorG = 1;
[Type(14, "float32")] public float colorB = 1;
[Type(15, "float32")] public float avx = 0;
[Type(16, "float32")] public float avy = 0;
[Type(17, "float32")] public float avz = 0;
[Type(18, "boolean")] public bool isEliminated = false;
[Type(19, "boolean")] public bool isQualified = false;
[Type(20, "boolean")] public bool isReady = false;
[Type(21, "int8")] public sbyte checkpointIndex = 0;
}
public partial class NetworkState : Schema
{
[Type(0, "map", typeof(MapSchema<NetworkPlayer>))]
public MapSchema<NetworkPlayer> players;
#if UNITY_5_3_OR_NEWER
[Preserve]
#endif
public NetworkState() { }
[Type(1, "string")] public string phase = "lobby";
[Type(2, "float32")] public float countdown = 0;
[Type(3, "int8")] public int roundNumber = 1;
[Type(4, "int8")] public int totalRounds = 4;
[Type(5, "int8")] public int playersAlive = 0;
[Type(6, "string")] public string gameMode = "race";
[Type(7, "float32")] public float deathZoneY = -100;
[Type(8, "int16")] public int teamScoreRed = 0;
[Type(9, "int16")] public int teamScoreBlue = 0;
[Type(10, "string")] public string winnerName = "";
[Type(0, "map", typeof(MapSchema<NetworkPlayer>))]
public MapSchema<NetworkPlayer> players = null;
[Type(1, "string")] public string phase = "lobby";
[Type(2, "float32")] public float countdown = 0;
[Type(3, "int8")] public sbyte roundNumber = 1;
[Type(4, "int8")] public sbyte totalRounds = 3;
[Type(5, "int8")] public sbyte playersAlive = 0;
[Type(6, "string")] public string gameMode = "race";
[Type(7, "string")] public string winnerName = "";
}

View File

@@ -241,17 +241,14 @@ public class RemotePlayerController : MonoBehaviour
transform.rotation = _currentRotation;
}
// Keep name label floating ABOVE the ball (world position, not local)
// Billboard: always face camera, locked to vertical axis
if (_nameLabelObj != null)
{
_nameLabelObj.transform.position = transform.position + Vector3.up * 1.5f;
var cam = Camera.main;
if (cam != null)
{
// Billboard locked to Y axis — only rotate around vertical
Vector3 lookDir = _nameLabelObj.transform.position - cam.transform.position;
lookDir.y = 0f; // Lock to horizontal plane
Vector3 lookDir = cam.transform.position - _nameLabelObj.transform.position;
lookDir.y = 0f;
if (lookDir.sqrMagnitude > 0.001f)
_nameLabelObj.transform.rotation = Quaternion.LookRotation(lookDir);
}

View File

@@ -1,152 +0,0 @@
using UnityEngine;
using System.Collections;
/// <summary>
/// Manages race checkpoints and the finish line.
/// Place checkpoint GameObjects in order in the Inspector array.
/// Each checkpoint needs a Collider set to "Is Trigger".
/// The last checkpoint in the array is treated as the finish line.
/// Attach to a persistent manager GameObject in the race scene.
/// </summary>
public class CheckpointSystem : MonoBehaviour
{
public static CheckpointSystem Instance { get; private set; }
[Header("Checkpoints (in order — last one = finish line)")]
public Collider[] checkpoints;
[Header("Visuals")]
[Tooltip("Material to apply to active (next) checkpoint")]
public Material checkpointActiveMaterial;
[Tooltip("Material to apply to passed checkpoints")]
public Material checkpointPassedMaterial;
[Tooltip("Material to apply to finish line")]
public Material finishLineMaterial;
private int _localCheckpointIndex = 0; // how many checkpoints this local player passed
private Renderer[] _checkpointRenderers;
private bool _finished = false;
void Awake()
{
Instance = this;
}
void Start()
{
_checkpointRenderers = new Renderer[checkpoints.Length];
for (int i = 0; i < checkpoints.Length; i++)
{
_checkpointRenderers[i] = checkpoints[i].GetComponent<Renderer>();
// Tag checkpoints with their index for trigger identification
checkpoints[i].gameObject.name = $"Checkpoint_{i}";
}
// Tell HUD total checkpoints
GameHUD.TotalCheckpoints = checkpoints.Length;
UpdateCheckpointVisuals();
}
/// <summary>Called by CheckpointTrigger on each checkpoint object.</summary>
public void OnLocalPlayerHitCheckpoint(int index)
{
if (_finished) return;
// Must hit checkpoints in order
if (index != _localCheckpointIndex) return;
_localCheckpointIndex++;
NetworkManager.Instance?.SendCheckpoint(_localCheckpointIndex);
Debug.Log($"[Checkpoint] Reached {_localCheckpointIndex}/{checkpoints.Length}");
// Update HUD
GameHUD.Instance?.SetCheckpoint(_localCheckpointIndex, checkpoints.Length);
UpdateCheckpointVisuals();
if (_localCheckpointIndex >= checkpoints.Length)
{
_finished = true;
Debug.Log("[Checkpoint] FINISH LINE reached!");
StartCoroutine(FinishFlash());
}
else
{
StartCoroutine(FlashCheckpoint(index));
}
}
public void ResetForRound()
{
_localCheckpointIndex = 0;
_finished = false;
UpdateCheckpointVisuals();
}
private void UpdateCheckpointVisuals()
{
for (int i = 0; i < checkpoints.Length; i++)
{
if (_checkpointRenderers[i] == null) continue;
if (i < _localCheckpointIndex)
{
// Passed
if (checkpointPassedMaterial != null)
_checkpointRenderers[i].material = checkpointPassedMaterial;
_checkpointRenderers[i].enabled = false; // hide passed checkpoints
}
else if (i == _localCheckpointIndex)
{
// Active (next to hit)
_checkpointRenderers[i].enabled = true;
if (i == checkpoints.Length - 1 && finishLineMaterial != null)
_checkpointRenderers[i].material = finishLineMaterial;
else if (checkpointActiveMaterial != null)
_checkpointRenderers[i].material = checkpointActiveMaterial;
}
else
{
// Upcoming (not yet active)
_checkpointRenderers[i].enabled = true;
}
}
}
private IEnumerator FlashCheckpoint(int index)
{
if (index < 0 || index >= checkpoints.Length) yield break;
var rend = _checkpointRenderers[index];
if (rend == null) yield break;
Color orig = rend.material.HasProperty("_BaseColor")
? rend.material.GetColor("_BaseColor")
: rend.material.color;
for (int i = 0; i < 3; i++)
{
SetRendererColor(rend, new Color(0.3f, 1f, 0.5f));
yield return new WaitForSeconds(0.08f);
SetRendererColor(rend, orig);
yield return new WaitForSeconds(0.08f);
}
}
private IEnumerator FinishFlash()
{
float t = 0f;
while (t < 2f)
{
t += Time.deltaTime;
yield return null;
}
// Finish confirmed via network — GameManager/EliminationOverlay handles the "Qualifié!" overlay
}
private static void SetRendererColor(Renderer rend, Color c)
{
if (rend.material.HasProperty("_BaseColor")) rend.material.SetColor("_BaseColor", c);
else rend.material.color = c;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5bf5e078a2ee9ed4fa95eacab5753f3a

View File

@@ -1,18 +0,0 @@
using UnityEngine;
/// <summary>
/// Attach to each checkpoint GameObject (which must have a trigger Collider).
/// Set the checkpointIndex in the Inspector to match the checkpoint's position in the sequence.
/// </summary>
public class CheckpointTrigger : MonoBehaviour
{
[Tooltip("Index in the CheckpointSystem.checkpoints array (0-based)")]
public int checkpointIndex = 0;
void OnTriggerEnter(Collider other)
{
// Only trigger for the local player (has PlayerController)
if (other.GetComponent<PlayerController>() == null) return;
CheckpointSystem.Instance?.OnLocalPlayerHitCheckpoint(checkpointIndex);
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6d1f3d6aaca8e97498f40d827f7c5216

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: ba062aa6c92b140379dbc06b43dd3b9b
guid: e9c4c0760bb30024293b8152d79c595e
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -0,0 +1,178 @@
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
/// <summary>
/// Tracks player statistics and uploads them to the game server every 30s + on disconnect.
/// No dependency on round events — works even if Colyseus callbacks are broken.
/// </summary>
public class StatsTracker : MonoBehaviour
{
public static StatsTracker Instance { get; private set; }
private const string SERVER_URL = "https://game.rolld.kerboul.me";
private const float SEND_INTERVAL = 30f;
private const float MIN_SEND_INTERVAL = 6f; // juste au-dessus du rate-limit serveur (5s)
// Cumulative stats
private float _totalDistance;
private int _totalJumps;
private float _maxSpeed;
private int _bumpsGiven;
// Playtime
private float _sessionStart;
private float _playtimeSentSoFar; // how much playtime we already sent
// Tracking
private Vector3 _lastPos;
private bool _tracking;
private string _cachedName = "";
private float _lastSentTime = -999f;
private PlayerController _pc;
private Rigidbody _rb;
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
}
void Start()
{
_pc = GetComponent<PlayerController>();
_rb = GetComponent<Rigidbody>();
var nm = NetworkManager.Instance;
if (nm != null)
{
nm.OnConnected += OnConnected;
nm.OnDisconnected += OnDisconnected;
}
}
void OnDestroy()
{
var nm = NetworkManager.Instance;
if (nm != null)
{
nm.OnConnected -= OnConnected;
nm.OnDisconnected -= OnDisconnected;
}
}
void FixedUpdate()
{
if (!_tracking || _rb == null) return;
Vector3 pos = transform.position;
float delta = Vector3.Distance(pos, _lastPos);
if (delta < 20f) // filtre téléportations
_totalDistance += delta;
_lastPos = pos;
float speed = _rb.linearVelocity.magnitude;
if (speed > _maxSpeed) _maxSpeed = speed;
}
// ─── Public hooks ─────────────────────────────────────────────────────
public void RegisterJump() => _totalJumps++;
public void RegisterBump() => _bumpsGiven++;
// ─── Connection events ────────────────────────────────────────────────
private void OnConnected()
{
_cachedName = NetworkManager.Instance?.LocalPlayerName ?? "";
_lastPos = transform.position;
_sessionStart = Time.time;
_tracking = true;
StartCoroutine(PeriodicSend());
}
private void OnDisconnected()
{
_tracking = false;
StopAllCoroutines();
SendStats(); // envoi final best-effort
}
// ─── Periodic send ────────────────────────────────────────────────────
private IEnumerator PeriodicSend()
{
while (_tracking)
{
yield return new WaitForSeconds(SEND_INTERVAL);
if (_tracking) SendStats();
}
}
// ─── HTTP send ────────────────────────────────────────────────────────
private void SendStats()
{
if (Time.time - _lastSentTime < MIN_SEND_INTERVAL) return;
var nm = NetworkManager.Instance;
string name = (nm != null && !string.IsNullOrEmpty(nm.LocalPlayerName))
? nm.LocalPlayerName
: _cachedName;
if (string.IsNullOrEmpty(name)) return;
_lastSentTime = Time.time;
StartCoroutine(DoSendStats(name));
}
private IEnumerator DoSendStats(string playerName)
{
float now = Time.time;
float sessionSecs = now - _sessionStart;
float playtimeToSend = sessionSecs - _playtimeSentSoFar;
_playtimeSentSoFar = sessionSecs;
var payload = new StatsPayload
{
name = playerName,
stats = new StatsData
{
totalDistance = _totalDistance,
totalJumps = _totalJumps,
maxSpeed = _maxSpeed,
bumpsGiven = _bumpsGiven,
totalPlaytime = playtimeToSend,
}
};
string json = JsonUtility.ToJson(payload);
byte[] body = Encoding.UTF8.GetBytes(json);
using var req = new UnityWebRequest($"{SERVER_URL}/stats/update", "POST");
req.uploadHandler = new UploadHandlerRaw(body);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
Debug.LogWarning($"[Stats] Upload failed: {req.error}");
else
Debug.Log($"[Stats] Sent for {playerName} — dist:{_totalDistance:F0}m spd:{_maxSpeed:F1}m/s jumps:{_totalJumps}");
}
// ─── DTOs ─────────────────────────────────────────────────────────────
[System.Serializable]
private class StatsPayload { public string name; public StatsData stats; }
[System.Serializable]
private class StatsData
{
public float totalDistance;
public int totalJumps;
public float maxSpeed;
public int bumpsGiven;
public float totalPlaytime;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f63c62c1f3656c644947907edfe2e07f

View File

@@ -1,93 +0,0 @@
using UnityEngine;
/// <summary>
/// Survival mode: a rising death zone that climbs from below.
/// The server is authoritative on the Y position (broadcast via NetworkState.deathZoneY).
/// This component moves the visual/collider locally, and detects local player contact.
///
/// Setup: Create a large Plane or Cube GameObject, attach this script,
/// add a Box/Mesh Collider set to "Is Trigger".
/// The object will be positioned at deathZoneY each frame.
/// </summary>
public class DeathZone : MonoBehaviour
{
[Header("Visual")]
[Tooltip("Half-size of the death zone plane (X and Z)")]
public float halfExtent = 200f;
[Tooltip("Thickness of the zone collider")]
public float thickness = 2f;
[Header("Warning")]
[Tooltip("Distance above death zone where the red tint starts")]
public float warningDistance = 8f;
private bool _hitSent = false;
private float _targetY = -100f;
void Start()
{
// Scale the collider to cover the arena
transform.localScale = new Vector3(halfExtent * 2f, thickness, halfExtent * 2f);
// Subscribe to death zone Y changes from server
if (NetworkManager.Instance != null)
{
NetworkManager.Instance.OnDeathZoneYChanged += OnDeathZoneYChanged;
NetworkManager.Instance.OnPhaseChanged += OnPhaseChanged;
}
}
void OnDestroy()
{
if (NetworkManager.Instance != null)
{
NetworkManager.Instance.OnDeathZoneYChanged -= OnDeathZoneYChanged;
NetworkManager.Instance.OnPhaseChanged -= OnPhaseChanged;
}
}
void OnDeathZoneYChanged(float y)
{
_targetY = y;
}
void OnPhaseChanged(string phase)
{
if (phase == "playing" || phase == "lobby")
{
_hitSent = false; // reset for new round
}
}
void Update()
{
// Smooth follow of the server Y value
Vector3 pos = transform.position;
pos.y = Mathf.Lerp(pos.y, _targetY, Time.deltaTime * 3f);
transform.position = pos;
// Update warning tint based on local player proximity
var nm = NetworkManager.Instance;
if (nm != null && nm.IsConnected && GameHUD.Instance != null)
{
var localState = nm.GetLocalPlayerState();
if (localState != null)
{
float dist = localState.y - pos.y;
float intensity = Mathf.Clamp01(1f - dist / warningDistance);
GameHUD.Instance.SetDeathZoneWarning(intensity);
}
}
}
void OnTriggerEnter(Collider other)
{
if (_hitSent) return;
if (other.GetComponent<PlayerController>() == null) return;
_hitSent = true;
Debug.Log("[DeathZone] Local player hit the death zone!");
NetworkManager.Instance?.SendDeathZoneHit();
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 99fdfaa3e87a64d4e958f81014e6cdab

View File

@@ -1,125 +0,0 @@
using UnityEngine;
/// <summary>
/// Teams mode: a central capture zone. The local player sends "inZone" messages
/// to the server while inside the zone. The server tallies scores.
/// Visualizes zone control by tinting the zone's renderer.
///
/// Setup: Create a flat Box/Plane in the center of the arena.
/// Add a BoxCollider set to "Is Trigger". Attach this script.
/// </summary>
public class ZoneCapture : MonoBehaviour
{
public static ZoneCapture Instance { get; private set; }
[Header("Visual")]
[Tooltip("Neutral zone color")]
public Color neutralColor = new Color(0.5f, 0.5f, 0.6f, 0.5f);
[Tooltip("Red team controls color")]
public Color redColor = new Color(1f, 0.2f, 0.2f, 0.6f);
[Tooltip("Blue team controls color")]
public Color blueColor = new Color(0.2f, 0.5f, 1f, 0.6f);
[Header("Score Reporting")]
[Tooltip("How often (seconds) to send inZone=true to server while inside")]
public float reportInterval = 0.5f;
private bool _isLocalPlayerInZone = false;
private float _reportTimer = 0f;
private Renderer _renderer;
// Zone occupant counts (received via server state — approximated by remote player positions)
private int _redInZone = 0;
private int _blueInZone = 0;
void Awake()
{
Instance = this;
_renderer = GetComponent<Renderer>();
SetZoneColor(neutralColor);
}
void Start()
{
if (NetworkManager.Instance != null)
{
NetworkManager.Instance.OnPhaseChanged += OnPhaseChanged;
}
}
void OnDestroy()
{
if (NetworkManager.Instance != null)
NetworkManager.Instance.OnPhaseChanged -= OnPhaseChanged;
}
void OnPhaseChanged(string phase)
{
if (phase == "lobby" || phase == "roundEnd")
{
_isLocalPlayerInZone = false;
_redInZone = 0;
_blueInZone = 0;
SetZoneColor(neutralColor);
}
}
void Update()
{
if (!_isLocalPlayerInZone) return;
_reportTimer += Time.deltaTime;
if (_reportTimer >= reportInterval)
{
_reportTimer = 0f;
NetworkManager.Instance?.SendInZone(true);
}
// Update zone tint based on dominance
UpdateZoneColor();
}
void OnTriggerEnter(Collider other)
{
if (other.GetComponent<PlayerController>() != null)
{
_isLocalPlayerInZone = true;
_reportTimer = 0f;
NetworkManager.Instance?.SendInZone(true);
}
// Count remote players in zone
var remote = other.GetComponent<RemotePlayerController>();
if (remote != null)
{
// team info would need to be tracked — skip for now, server handles scoring
}
}
void OnTriggerExit(Collider other)
{
if (other.GetComponent<PlayerController>() != null)
{
_isLocalPlayerInZone = false;
NetworkManager.Instance?.SendInZone(false);
}
}
private void UpdateZoneColor()
{
// Read team scores from NetworkManager state for visual feedback
// For now use a pulsing neutral tint when local player is inside
float pulse = 0.7f + 0.3f * Mathf.Sin(Time.time * 3f);
Color c = neutralColor;
c.a = pulse * 0.6f;
SetZoneColor(c);
}
public void SetZoneColor(Color c)
{
if (_renderer == null) return;
var mat = _renderer.material;
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", c);
else mat.color = c;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3e7c98b369c3ccf4aac0ad3ad2bcbbff

View File

@@ -0,0 +1,269 @@
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Networking;
/// <summary>
/// General chat panel. T to open, Escape to close.
/// Polls GET /chat/history every 3s and sends via POST /chat/send (or Colyseus if connected).
/// Uses ImGuiSkin for visual consistency.
/// </summary>
public class ChatUI : MonoBehaviour
{
public static ChatUI Instance { get; private set; }
public static bool IsVisible { get; private set; }
private const string SERVER_URL = "https://game.rolld.kerboul.me";
private const float POLL_INTERVAL = 3f;
private const int MAX_DISPLAY = 50;
private bool _visible;
private string _inputText = "";
private Vector2 _scrollPos;
private float _pollTimer;
private long _lastTimestamp;
private bool _autoScroll = true;
private readonly List<ChatMessage> _messages = new();
private int _unreadCount;
// Cached textures for badge
private static Texture2D _badgeTex;
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
// Initial load
StartCoroutine(DoPoll());
}
void Update()
{
if (Keyboard.current != null)
{
if (!_visible && Keyboard.current[Key.T].wasPressedThisFrame)
Toggle();
else if (_visible && Keyboard.current[Key.Escape].wasPressedThisFrame)
Toggle();
}
if (_visible)
{
_pollTimer += Time.deltaTime;
if (_pollTimer >= POLL_INTERVAL) { _pollTimer = 0f; StartCoroutine(DoPoll()); }
// Send on Enter (only when chat input has text)
if (!string.IsNullOrWhiteSpace(_inputText) &&
Keyboard.current != null && Keyboard.current[Key.Enter].wasPressedThisFrame)
{
TrySend();
}
}
}
private void Toggle()
{
_visible = !_visible;
IsVisible = _visible;
if (_visible)
{
_unreadCount = 0;
_autoScroll = true;
_pollTimer = POLL_INTERVAL; // poll immediately
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
// Release held movement keys so the ball doesn't keep moving while typing
FindFirstObjectByType<PlayerController>()?.ResetInputs();
}
else
{
// Only re-lock if no other UI is open
if (!KeyBindingUI.IsVisible)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
}
void OnGUI()
{
if (!_visible)
{
DrawBadge();
return;
}
ImGuiSkin.EnsureReady();
// Panel bottom-right, doesn't obstruct the center
float w = 460f;
float h = 440f;
float x = Screen.width - w - 12f;
float y = Screen.height - h - 12f;
ImGuiSkin.BeginWindowAt(x, y, w, h, "CHAT GÉNÉRAL");
// ── Message history ───────────────────────────────────────────
float listH = h - 130f;
_scrollPos = GUILayout.BeginScrollView(_scrollPos, ImGuiSkin.ScrollView, GUILayout.Height(listH));
foreach (var msg in _messages)
{
var ts = System.DateTimeOffset.FromUnixTimeMilliseconds(msg.timestamp).ToLocalTime();
string timeStr = ts.ToString("HH:mm");
var timeStyle = new GUIStyle(ImGuiSkin.LabelDim) { fontSize = 10, fixedWidth = 36f };
var nameStyle = new GUIStyle(ImGuiSkin.LabelBold);
nameStyle.normal.textColor = ImGuiSkin.ColAccent;
var textStyle = new GUIStyle(ImGuiSkin.LabelRich);
GUILayout.BeginHorizontal();
GUILayout.Label(timeStr, timeStyle);
GUILayout.Label(msg.name + " :", nameStyle, GUILayout.Width(100f));
GUILayout.Label(msg.text, textStyle);
GUILayout.EndHorizontal();
GUILayout.Space(1f);
}
if (_autoScroll) _scrollPos.y = float.MaxValue;
GUILayout.EndScrollView();
ImGuiSkin.Separator();
GUILayout.Space(4f);
// ── Input row ────────────────────────────────────────────────
GUILayout.BeginHorizontal();
GUI.SetNextControlName("ChatInput");
_inputText = GUILayout.TextField(_inputText, 200, ImGuiSkin.TextField, GUILayout.Height(28f));
bool canSend = !string.IsNullOrWhiteSpace(_inputText) && PlayerName.Length > 0;
if (PlayerName.Length == 0)
GUILayout.Label("Connectez-vous au jeu pour envoyer des messages.", ImGuiSkin.LabelDim);
GUI.enabled = canSend;
if (GUILayout.Button("Envoyer", ImGuiSkin.Button, GUILayout.Width(80f), GUILayout.Height(28f)))
TrySend();
GUI.enabled = true;
GUILayout.EndHorizontal();
GUILayout.Space(4f);
GUILayout.Label("T — Ouvrir · Échap — Fermer · Entrée — Envoyer", ImGuiSkin.Footer);
ImGuiSkin.EndWindow();
// Auto-focus input field
GUI.FocusControl("ChatInput");
}
private void DrawBadge()
{
if (_unreadCount <= 0) return;
if (_badgeTex == null)
{
_badgeTex = new Texture2D(1, 1);
_badgeTex.SetPixel(0, 0, Color.white);
_badgeTex.Apply();
}
float bx = Screen.width - 68f;
float by = Screen.height - 32f;
GUI.color = new Color(0.9f, 0.2f, 0.2f, 0.9f);
GUI.DrawTexture(new Rect(bx, by, 56f, 22f), _badgeTex);
GUI.color = Color.white;
var s = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11, fontStyle = FontStyle.Bold };
s.normal.textColor = Color.white;
GUI.Label(new Rect(bx, by, 56f, 22f), $"💬 {_unreadCount}", s);
}
// ─── Send ────────────────────────────────────────────────────────────
private string PlayerName
{
get
{
var nm = NetworkManager.Instance?.LocalPlayerName;
if (!string.IsNullOrEmpty(nm)) return nm;
return PlayerPrefs.GetString("rolld_player_name", "");
}
}
private void TrySend()
{
string text = _inputText.Trim();
if (string.IsNullOrEmpty(text) || PlayerName.Length == 0) return;
_inputText = "";
_autoScroll = true;
// Always use HTTP — the server endpoint broadcasts to Colyseus rooms anyway
StartCoroutine(DoSend(PlayerName, text));
}
// ─── HTTP polling ─────────────────────────────────────────────────────
private IEnumerator DoPoll()
{
string url = $"{SERVER_URL}/chat/history?since={_lastTimestamp}";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) yield break;
var wrapper = JsonUtility.FromJson<MessageListWrapper>($"{{\"items\":{req.downloadHandler.text}}}");
if (wrapper?.items == null) yield break;
int added = 0;
foreach (var msg in wrapper.items)
{
if (msg.timestamp > _lastTimestamp)
{
_messages.Add(msg);
_lastTimestamp = msg.timestamp;
added++;
}
}
if (_messages.Count > MAX_DISPLAY)
_messages.RemoveRange(0, _messages.Count - MAX_DISPLAY);
if (added > 0 && !_visible)
_unreadCount += added;
}
private IEnumerator DoSend(string name, string text)
{
var payload = new SendPayload { name = name, text = text };
string json = JsonUtility.ToJson(payload);
byte[] body = Encoding.UTF8.GetBytes(json);
using var req = new UnityWebRequest($"{SERVER_URL}/chat/send", "POST");
req.uploadHandler = new UploadHandlerRaw(body);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
// Immediate poll so the message appears right away
StartCoroutine(DoPoll());
}
// Called by NetworkManager when a "chat" message arrives via Colyseus
public void ReceiveChatMessage(ChatMessage msg)
{
if (_messages.Count >= MAX_DISPLAY)
_messages.RemoveAt(0);
_messages.Add(msg);
if (msg.timestamp > _lastTimestamp) _lastTimestamp = msg.timestamp;
if (!_visible) _unreadCount++;
_autoScroll = true;
}
// ─── DTOs ─────────────────────────────────────────────────────────────
[System.Serializable]
public class ChatMessage { public int id; public long timestamp; public string name; public string text; }
[System.Serializable]
private class SendPayload { public string name; public string text; }
[System.Serializable]
private class MessageListWrapper { public List<ChatMessage> items; }
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: baad3cbcb6161a548ba18d5272ba57c5

View File

@@ -1,142 +0,0 @@
using System.Collections;
using UnityEngine;
/// <summary>
/// Full-screen overlay for elimination, qualification, and game-end events.
/// Fade in → hold → fade out automatically.
/// </summary>
public class EliminationOverlay : MonoBehaviour
{
private enum OverlayType { None, Eliminated, Qualified, GameEnd }
private OverlayType _type = OverlayType.None;
private float _alpha = 0f;
private string _winnerName = "";
private static Texture2D _bgTex;
void Start()
{
if (_bgTex == null)
{
_bgTex = new Texture2D(1, 1);
_bgTex.SetPixel(0, 0, Color.white);
_bgTex.Apply();
}
}
public void ShowEliminated() => StartCoroutine(ShowOverlay(OverlayType.Eliminated, 3f));
public void ShowQualified() => StartCoroutine(ShowOverlay(OverlayType.Qualified, 2.5f));
public void ShowGameEnd(string winner)
{
_winnerName = winner;
StartCoroutine(ShowOverlay(OverlayType.GameEnd, 6f));
}
private IEnumerator ShowOverlay(OverlayType type, float holdTime)
{
_type = type;
// Fade in
float t = 0f;
while (t < 0.3f)
{
t += Time.deltaTime;
_alpha = Mathf.Clamp01(t / 0.3f);
yield return null;
}
_alpha = 1f;
// Hold
yield return new WaitForSeconds(holdTime);
// Fade out
t = 0f;
while (t < 0.4f)
{
t += Time.deltaTime;
_alpha = 1f - Mathf.Clamp01(t / 0.4f);
yield return null;
}
_alpha = 0f;
_type = OverlayType.None;
}
void OnGUI()
{
if (_type == OverlayType.None || _alpha < 0.01f) return;
// Background tint
Color bgColor = _type switch
{
OverlayType.Eliminated => new Color(0.7f, 0.05f, 0.05f, _alpha * 0.55f),
OverlayType.Qualified => new Color(0.05f, 0.55f, 0.15f, _alpha * 0.45f),
OverlayType.GameEnd => new Color(0.05f, 0.05f, 0.3f, _alpha * 0.6f),
_ => Color.clear
};
GUI.color = bgColor;
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), _bgTex);
GUI.color = Color.white;
// Main text
var mainStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 72,
fontStyle = FontStyle.Bold,
};
string mainText = _type switch
{
OverlayType.Eliminated => "ÉLIMINÉ !",
OverlayType.Qualified => "QUALIFIÉ !",
OverlayType.GameEnd => "VICTOIRE !",
_ => ""
};
Color textColor = _type switch
{
OverlayType.Eliminated => new Color(1f, 0.3f, 0.2f, _alpha),
OverlayType.Qualified => new Color(0.3f, 1f, 0.5f, _alpha),
OverlayType.GameEnd => new Color(1f, 0.85f, 0.1f, _alpha),
_ => Color.clear
};
mainStyle.normal.textColor = textColor;
GUI.Label(new Rect(0, Screen.height * 0.35f, Screen.width, 100f), mainText, mainStyle);
// Sub text
var subStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 26,
fontStyle = FontStyle.Bold,
};
subStyle.normal.textColor = new Color(1f, 1f, 1f, _alpha * 0.85f);
string subText = _type switch
{
OverlayType.Eliminated => "Meilleure chance la prochaine fois !",
OverlayType.Qualified => "Tu passes au round suivant !",
OverlayType.GameEnd => $"Gagnant : {_winnerName}",
_ => ""
};
GUI.Label(new Rect(0, Screen.height * 0.35f + 100f, Screen.width, 50f), subText, subStyle);
// Emoji accent
var emojiStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 48,
};
emojiStyle.normal.textColor = new Color(1f, 1f, 1f, _alpha * 0.7f);
string emoji = _type switch
{
OverlayType.Eliminated => "💀",
OverlayType.Qualified => "✅",
OverlayType.GameEnd => "🏆",
_ => ""
};
GUI.Label(new Rect(0, Screen.height * 0.35f - 80f, Screen.width, 70f), emoji, emojiStyle);
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 51e21afb9dba1904bb425ac1fae825cb

View File

@@ -16,9 +16,10 @@ public class GameHUD : MonoBehaviour
private float _roundTimer = 0f;
private bool _timerRunning = false;
// Checkpoint info (set by CheckpointSystem)
private int _checkpointsCurrent = 0;
private int _checkpointsTotal = 5;
// Local race state (activated when CP0 gate is crossed, independent of server phase)
private bool _localRaceActive = false;
private float _localRaceTimer = 0f;
// Countdown animation
private float _lastCountdownShown = -1f;
@@ -53,13 +54,13 @@ public class GameHUD : MonoBehaviour
}
}
void OnRoundStart(int round, string mode)
void OnRoundStart(int round, string mode, int totalRounds)
{
_roundNumber = round;
_totalRounds = totalRounds;
_gameMode = mode;
_roundTimer = 0f;
_timerRunning = true;
_checkpointsCurrent = 0;
}
void OnPhaseChanged(string phase)
@@ -73,6 +74,8 @@ public class GameHUD : MonoBehaviour
{
if (_timerRunning)
_roundTimer += Time.deltaTime;
if (_localRaceActive)
_localRaceTimer += Time.deltaTime;
if (_countdown > 0f && _countdown != _lastCountdownShown)
{
@@ -82,14 +85,23 @@ public class GameHUD : MonoBehaviour
_countdownPulse = Mathf.Max(0f, _countdownPulse - Time.deltaTime * 3f);
}
public float LocalRaceTimer => _localRaceTimer;
public void SetPhase(string phase) => _phase = phase;
public void SetCountdown(float v) => _countdown = v;
public void SetRoundInfo(int round, string mode) { _roundNumber = round; _gameMode = mode; }
public void SetCheckpoint(int current, int total) { _checkpointsCurrent = current; _checkpointsTotal = total; }
public void SetTotalRounds(int n) => _totalRounds = n;
public void SetLocalRaceActive(bool active)
{
_localRaceActive = active;
if (!active) _localRaceTimer = 0f;
}
void OnGUI()
{
if (_phase == "lobby") return;
if (_phase == "lobby" && !_localRaceActive) return;
ImGuiSkin.EnsureReady();
var nm = NetworkManager.Instance;
@@ -156,15 +168,6 @@ public class GameHUD : MonoBehaviour
GUI.Label(new Rect(panelX + 8f, panelY + 32f, panelW - 16f, 24f), modeFull, modeStyle);
// ── Top-right: Players alive ──────────────────────────────────────
int alive = nm?.GetLocalPlayerState() != null
? (_room_playersAlive > 0 ? _room_playersAlive : 1)
: 0;
if (nm != null)
{
// read from room state if accessible
}
float prX = Screen.width - 180f;
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
GUI.DrawTexture(new Rect(prX, panelY, 168f, panelH), _bgTex);
@@ -184,10 +187,11 @@ public class GameHUD : MonoBehaviour
GUI.Label(new Rect(prX, panelY + 40f, 168f, 22f), "joueurs en jeu", aliveLabel);
// ── Round timer (top center) ──────────────────────────────────────
if (_timerRunning)
float displayTimer = _timerRunning ? _roundTimer : (_localRaceActive ? _localRaceTimer : -1f);
if (displayTimer >= 0f)
{
int mins = Mathf.FloorToInt(_roundTimer / 60f);
int secs = Mathf.FloorToInt(_roundTimer % 60f);
int mins = Mathf.FloorToInt(displayTimer / 60f);
int secs = Mathf.FloorToInt(displayTimer % 60f);
var timerStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
@@ -199,105 +203,15 @@ public class GameHUD : MonoBehaviour
$"{mins:00}:{secs:00}", timerStyle);
}
// ── Race: checkpoint progress (bottom center) ─────────────────────
if (_gameMode == "race" && _phase == "playing")
{
float bw = 300f;
float bx = (Screen.width - bw) / 2f;
float by = Screen.height - 60f;
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
GUI.DrawTexture(new Rect(bx - 8f, by - 8f, bw + 16f, 36f), _bgTex);
GUI.color = Color.white;
// Background bar
GUI.color = new Color(0.2f, 0.2f, 0.28f, 1f);
GUI.DrawTexture(new Rect(bx, by, bw, 20f), _barBgTex);
// Fill
float fill = _checkpointsTotal > 0 ? (float)_checkpointsCurrent / _checkpointsTotal : 0f;
GUI.color = new Color(0.3f, 1f, 0.5f, 1f);
GUI.DrawTexture(new Rect(bx, by, bw * fill, 20f), _barFillTex);
GUI.color = Color.white;
var cpStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
cpStyle.normal.textColor = Color.white;
GUI.Label(new Rect(bx, by, bw, 20f),
$"Checkpoint {_checkpointsCurrent} / {_checkpointsTotal}", cpStyle);
}
// ── Teams: score display (bottom center) ──────────────────────────
if (_gameMode == "teams" && _phase == "playing")
{
float tw = 260f;
float tx = (Screen.width - tw) / 2f;
float ty = Screen.height - 60f;
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
GUI.DrawTexture(new Rect(tx - 8f, ty - 8f, tw + 16f, 36f), _bgTex);
GUI.color = Color.white;
var teamStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 20, fontStyle = FontStyle.Bold };
// Red team score
teamStyle.normal.textColor = new Color(1f, 0.3f, 0.3f);
GUI.Label(new Rect(tx, ty - 2f, tw * 0.4f, 28f), $"{_cachedScoreRed}", teamStyle);
// Separator
var sepStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 16 };
sepStyle.normal.textColor = new Color(0.5f, 0.5f, 0.6f);
GUI.Label(new Rect(tx + tw * 0.4f, ty - 2f, tw * 0.2f, 28f), "vs", sepStyle);
// Blue team score
teamStyle.normal.textColor = new Color(0.3f, 0.6f, 1f);
GUI.Label(new Rect(tx + tw * 0.6f, ty - 2f, tw * 0.4f, 28f), $"{_cachedScoreBlue}", teamStyle);
}
// ── Survival: death zone warning ──────────────────────────────────
if (_gameMode == "survival" && _phase == "playing" && _deathZoneWarning > 0.01f)
{
GUI.color = new Color(1f, 0.3f, 0.1f, _deathZoneWarning * 0.4f);
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), _bgTex);
GUI.color = Color.white;
var warnStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 20, fontStyle = FontStyle.Bold };
warnStyle.normal.textColor = new Color(1f, 0.4f, 0.2f, _deathZoneWarning);
GUI.Label(new Rect(0, Screen.height * 0.8f, Screen.width, 36f), "⚠ ZONE DE MORT MONTE !", warnStyle);
}
}
// Static accessors for cross-script use
public static GameHUD Instance { get; private set; }
public static int TotalCheckpoints { get; set; } = 5;
// Cached values updated from NetworkManager state polling
private int _cachedPlayersAlive = 0;
private int _cachedScoreRed = 0;
private int _cachedScoreBlue = 0;
private float _deathZoneWarning = 0f;
private int _room_playersAlive = 0;
void LateUpdate()
{
// Poll NetworkManager for display values (avoids tight coupling via events for display-only data)
if (NetworkManager.Instance == null || !NetworkManager.Instance.IsConnected) return;
// Survival: check death zone proximity
if (_gameMode == "survival")
{
var localState = NetworkManager.Instance.GetLocalPlayerState();
if (localState != null)
{
// deathZoneY is synced via NetworkState — we read via a static accessor pattern
// For now, warn when player Y is within 5 units above death zone
// (actual deathZoneY is not directly accessible here without extra plumbing)
}
}
}
// Called by DeathZone.cs to update the warning
public void SetDeathZoneWarning(float intensity) => _deathZoneWarning = intensity;
public void SetTeamScores(int red, int blue) { _cachedScoreRed = red; _cachedScoreBlue = blue; }
public void SetPlayersAlive(int count) => _cachedPlayersAlive = count;
private static void EnsureTextures()

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +0,0 @@
fileFormatVersion: 2
guid: 8a0c9218a650547d98138cd835033977
folderAsset: yes
timeCreated: 1484670163
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -1,134 +0,0 @@
fileFormatVersion: 2
guid: 727a75301c3d24613a3ebcec4a24c2c8
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 11
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 0
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 2
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,654 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &1
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12004, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_PixelRect:
serializedVersion: 2
x: 0
y: 45
width: 1666
height: 958
m_ShowMode: 4
m_Title:
m_RootView: {fileID: 6}
m_MinSize: {x: 950, y: 542}
m_MaxSize: {x: 10000, y: 10000}
--- !u!114 &2
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 466
width: 290
height: 442
m_MinSize: {x: 234, y: 271}
m_MaxSize: {x: 10004, y: 10021}
m_ActualView: {fileID: 14}
m_Panes:
- {fileID: 14}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &3
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 4}
- {fileID: 2}
m_Position:
serializedVersion: 2
x: 973
y: 0
width: 290
height: 908
m_MinSize: {x: 234, y: 492}
m_MaxSize: {x: 10004, y: 14042}
vertical: 1
controlID: 226
--- !u!114 &4
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 290
height: 466
m_MinSize: {x: 204, y: 221}
m_MaxSize: {x: 4004, y: 4021}
m_ActualView: {fileID: 17}
m_Panes:
- {fileID: 17}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &5
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 466
width: 973
height: 442
m_MinSize: {x: 202, y: 221}
m_MaxSize: {x: 4002, y: 4021}
m_ActualView: {fileID: 15}
m_Panes:
- {fileID: 15}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &6
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12008, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 7}
- {fileID: 8}
- {fileID: 9}
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 1666
height: 958
m_MinSize: {x: 950, y: 542}
m_MaxSize: {x: 10000, y: 10000}
--- !u!114 &7
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12011, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 1666
height: 30
m_MinSize: {x: 0, y: 0}
m_MaxSize: {x: 0, y: 0}
m_LastLoadedLayoutName: Tutorial
--- !u!114 &8
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 10}
- {fileID: 3}
- {fileID: 11}
m_Position:
serializedVersion: 2
x: 0
y: 30
width: 1666
height: 908
m_MinSize: {x: 713, y: 492}
m_MaxSize: {x: 18008, y: 14042}
vertical: 0
controlID: 74
--- !u!114 &9
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12042, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 938
width: 1666
height: 20
m_MinSize: {x: 0, y: 0}
m_MaxSize: {x: 0, y: 0}
--- !u!114 &10
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 12}
- {fileID: 5}
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 973
height: 908
m_MinSize: {x: 202, y: 442}
m_MaxSize: {x: 4002, y: 8042}
vertical: 1
controlID: 75
--- !u!114 &11
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 1263
y: 0
width: 403
height: 908
m_MinSize: {x: 277, y: 71}
m_MaxSize: {x: 4002, y: 4021}
m_ActualView: {fileID: 13}
m_Panes:
- {fileID: 13}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &12
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 973
height: 466
m_MinSize: {x: 202, y: 221}
m_MaxSize: {x: 4002, y: 4021}
m_ActualView: {fileID: 16}
m_Panes:
- {fileID: 16}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &13
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12019, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 0
m_MinSize: {x: 275, y: 50}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Inspector
m_Image: {fileID: -6905738622615590433, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 0
m_Pos:
serializedVersion: 2
x: 2
y: 19
width: 401
height: 887
m_ScrollPosition: {x: 0, y: 0}
m_InspectorMode: 0
m_PreviewResizer:
m_CachedPref: -160
m_ControlHash: -371814159
m_PrefName: Preview_InspectorPreview
m_PreviewWindow: {fileID: 0}
--- !u!114 &14
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12014, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 0
m_MinSize: {x: 230, y: 250}
m_MaxSize: {x: 10000, y: 10000}
m_TitleContent:
m_Text: Project
m_Image: {fileID: -7501376956915960154, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 0
m_Pos:
serializedVersion: 2
x: 2
y: 19
width: 286
height: 421
m_SearchFilter:
m_NameFilter:
m_ClassNames: []
m_AssetLabels: []
m_AssetBundleNames: []
m_VersionControlStates: []
m_ReferencingInstanceIDs:
m_ScenePaths: []
m_ShowAllHits: 0
m_SearchArea: 0
m_Folders:
- Assets
m_ViewMode: 0
m_StartGridSize: 64
m_LastFolders:
- Assets
m_LastFoldersGridSize: -1
m_LastProjectPath: /Users/danielbrauer/Unity Projects/New Unity Project 47
m_IsLocked: 0
m_FolderTreeState:
scrollPos: {x: 0, y: 0}
m_SelectedIDs: ee240000
m_LastClickedID: 9454
m_ExpandedIDs: ee24000000ca9a3bffffff7f
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 1
m_ClientGUIView: {fileID: 0}
m_SearchString:
m_CreateAssetUtility:
m_EndAction: {fileID: 0}
m_InstanceID: 0
m_Path:
m_Icon: {fileID: 0}
m_ResourceFile:
m_AssetTreeState:
scrollPos: {x: 0, y: 0}
m_SelectedIDs: 68fbffff
m_LastClickedID: 0
m_ExpandedIDs: ee240000
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 1
m_ClientGUIView: {fileID: 0}
m_SearchString:
m_CreateAssetUtility:
m_EndAction: {fileID: 0}
m_InstanceID: 0
m_Path:
m_Icon: {fileID: 0}
m_ResourceFile:
m_ListAreaState:
m_SelectedInstanceIDs: 68fbffff
m_LastClickedInstanceID: -1176
m_HadKeyboardFocusLastEvent: 0
m_ExpandedInstanceIDs: c6230000
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 1
m_ClientGUIView: {fileID: 0}
m_CreateAssetUtility:
m_EndAction: {fileID: 0}
m_InstanceID: 0
m_Path:
m_Icon: {fileID: 0}
m_ResourceFile:
m_NewAssetIndexInList: -1
m_ScrollPosition: {x: 0, y: 0}
m_GridSize: 64
m_DirectoriesAreaWidth: 110
--- !u!114 &15
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12015, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 1
m_MinSize: {x: 200, y: 200}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Game
m_Image: {fileID: -2087823869225018852, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 32
m_Pos:
serializedVersion: 2
x: 0
y: 19
width: 971
height: 421
m_MaximizeOnPlay: 0
m_Gizmos: 0
m_Stats: 0
m_SelectedSizes: 00000000000000000000000000000000000000000000000000000000000000000000000000000000
m_TargetDisplay: 0
m_ZoomArea:
m_HRangeLocked: 0
m_VRangeLocked: 0
m_HBaseRangeMin: -242.75
m_HBaseRangeMax: 242.75
m_VBaseRangeMin: -101
m_VBaseRangeMax: 101
m_HAllowExceedBaseRangeMin: 1
m_HAllowExceedBaseRangeMax: 1
m_VAllowExceedBaseRangeMin: 1
m_VAllowExceedBaseRangeMax: 1
m_ScaleWithWindow: 0
m_HSlider: 0
m_VSlider: 0
m_IgnoreScrollWheelUntilClicked: 0
m_EnableMouseInput: 1
m_EnableSliderZoom: 0
m_UniformScale: 1
m_UpDirection: 1
m_DrawArea:
serializedVersion: 2
x: 0
y: 17
width: 971
height: 404
m_Scale: {x: 2, y: 2}
m_Translation: {x: 485.5, y: 202}
m_MarginLeft: 0
m_MarginRight: 0
m_MarginTop: 0
m_MarginBottom: 0
m_LastShownAreaInsideMargins:
serializedVersion: 2
x: -242.75
y: -101
width: 485.5
height: 202
m_MinimalGUI: 1
m_defaultScale: 2
m_TargetTexture: {fileID: 0}
m_CurrentColorSpace: 0
m_LastWindowPixelSize: {x: 1942, y: 842}
m_ClearInEditMode: 1
m_NoCameraWarning: 1
m_LowResolutionForAspectRatios: 01000000000100000100
--- !u!114 &16
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12013, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 1
m_MinSize: {x: 200, y: 200}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Scene
m_Image: {fileID: 2318424515335265636, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 32
m_Pos:
serializedVersion: 2
x: 0
y: 19
width: 971
height: 445
m_SceneLighting: 1
lastFramingTime: 0
m_2DMode: 0
m_isRotationLocked: 0
m_AudioPlay: 0
m_Position:
m_Target: {x: 0, y: 0, z: 0}
speed: 2
m_Value: {x: 0, y: 0, z: 0}
m_RenderMode: 0
m_ValidateTrueMetals: 0
m_SceneViewState:
showFog: 1
showMaterialUpdate: 0
showSkybox: 1
showFlares: 1
showImageEffects: 1
grid:
xGrid:
m_Target: 0
speed: 2
m_Value: 0
yGrid:
m_Target: 1
speed: 2
m_Value: 1
zGrid:
m_Target: 0
speed: 2
m_Value: 0
m_Rotation:
m_Target: {x: -0.08717229, y: 0.89959055, z: -0.21045254, w: -0.3726226}
speed: 2
m_Value: {x: -0.08717229, y: 0.89959055, z: -0.21045254, w: -0.3726226}
m_Size:
m_Target: 10
speed: 2
m_Value: 10
m_Ortho:
m_Target: 0
speed: 2
m_Value: 0
m_LastSceneViewRotation: {x: 0, y: 0, z: 0, w: 0}
m_LastSceneViewOrtho: 0
m_ReplacementShader: {fileID: 0}
m_ReplacementString:
m_LastLockedObject: {fileID: 0}
m_ViewIsLockedToObject: 0
--- !u!114 &17
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12061, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 0
m_MinSize: {x: 200, y: 200}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Hierarchy
m_Image: {fileID: -590624980919486359, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 0
m_Pos:
serializedVersion: 2
x: 2
y: 19
width: 286
height: 445
m_TreeViewState:
scrollPos: {x: 0, y: 0}
m_SelectedIDs: 68fbffff
m_LastClickedID: -1176
m_ExpandedIDs: 7efbffff00000000
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 0
m_ClientGUIView: {fileID: 0}
m_SearchString:
m_ExpandedScenes:
-
m_CurrenRootInstanceID: 0
m_Locked: 0
m_CurrentSortingName: TransformSorting

View File

@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: eabc9546105bf4accac1fd62a63e88e6
timeCreated: 1487337779
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,9 +0,0 @@
fileFormatVersion: 2
guid: 5a9bcd70e6a4b4b05badaa72e827d8e0
folderAsset: yes
timeCreated: 1475835190
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,9 +0,0 @@
fileFormatVersion: 2
guid: 3ad9b87dffba344c89909c6d1b1c17e1
folderAsset: yes
timeCreated: 1475593892
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,242 +0,0 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System;
using System.IO;
using System.Reflection;
[CustomEditor(typeof(Readme))]
[InitializeOnLoad]
public class ReadmeEditor : Editor
{
static string s_ShowedReadmeSessionStateName = "ReadmeEditor.showedReadme";
static string s_ReadmeSourceDirectory = "Assets/TutorialInfo";
const float k_Space = 16f;
static ReadmeEditor()
{
EditorApplication.delayCall += SelectReadmeAutomatically;
}
static void RemoveTutorial()
{
if (EditorUtility.DisplayDialog("Remove Readme Assets",
$"All contents under {s_ReadmeSourceDirectory} will be removed, are you sure you want to proceed?",
"Proceed",
"Cancel"))
{
if (Directory.Exists(s_ReadmeSourceDirectory))
{
FileUtil.DeleteFileOrDirectory(s_ReadmeSourceDirectory);
FileUtil.DeleteFileOrDirectory(s_ReadmeSourceDirectory + ".meta");
}
else
{
Debug.Log($"Could not find the Readme folder at {s_ReadmeSourceDirectory}");
}
var readmeAsset = SelectReadme();
if (readmeAsset != null)
{
var path = AssetDatabase.GetAssetPath(readmeAsset);
FileUtil.DeleteFileOrDirectory(path + ".meta");
FileUtil.DeleteFileOrDirectory(path);
}
AssetDatabase.Refresh();
}
}
static void SelectReadmeAutomatically()
{
if (!SessionState.GetBool(s_ShowedReadmeSessionStateName, false))
{
var readme = SelectReadme();
SessionState.SetBool(s_ShowedReadmeSessionStateName, true);
if (readme && !readme.loadedLayout)
{
LoadLayout();
readme.loadedLayout = true;
}
}
}
static void LoadLayout()
{
var assembly = typeof(EditorApplication).Assembly;
var windowLayoutType = assembly.GetType("UnityEditor.WindowLayout", true);
var method = windowLayoutType.GetMethod("LoadWindowLayout", BindingFlags.Public | BindingFlags.Static);
method.Invoke(null, new object[] { Path.Combine(Application.dataPath, "TutorialInfo/Layout.wlt"), false });
}
static Readme SelectReadme()
{
var ids = AssetDatabase.FindAssets("Readme t:Readme");
if (ids.Length == 1)
{
var readmeObject = AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(ids[0]));
Selection.objects = new UnityEngine.Object[] { readmeObject };
return (Readme)readmeObject;
}
else
{
Debug.Log("Couldn't find a readme");
return null;
}
}
protected override void OnHeaderGUI()
{
var readme = (Readme)target;
Init();
var iconWidth = Mathf.Min(EditorGUIUtility.currentViewWidth / 3f - 20f, 128f);
GUILayout.BeginHorizontal("In BigTitle");
{
if (readme.icon != null)
{
GUILayout.Space(k_Space);
GUILayout.Label(readme.icon, GUILayout.Width(iconWidth), GUILayout.Height(iconWidth));
}
GUILayout.Space(k_Space);
GUILayout.BeginVertical();
{
GUILayout.FlexibleSpace();
GUILayout.Label(readme.title, TitleStyle);
GUILayout.FlexibleSpace();
}
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
}
GUILayout.EndHorizontal();
}
public override void OnInspectorGUI()
{
var readme = (Readme)target;
Init();
foreach (var section in readme.sections)
{
if (!string.IsNullOrEmpty(section.heading))
{
GUILayout.Label(section.heading, HeadingStyle);
}
if (!string.IsNullOrEmpty(section.text))
{
GUILayout.Label(section.text, BodyStyle);
}
if (!string.IsNullOrEmpty(section.linkText))
{
if (LinkLabel(new GUIContent(section.linkText)))
{
Application.OpenURL(section.url);
}
}
GUILayout.Space(k_Space);
}
if (GUILayout.Button("Remove Readme Assets", ButtonStyle))
{
RemoveTutorial();
}
}
bool m_Initialized;
GUIStyle LinkStyle
{
get { return m_LinkStyle; }
}
[SerializeField]
GUIStyle m_LinkStyle;
GUIStyle TitleStyle
{
get { return m_TitleStyle; }
}
[SerializeField]
GUIStyle m_TitleStyle;
GUIStyle HeadingStyle
{
get { return m_HeadingStyle; }
}
[SerializeField]
GUIStyle m_HeadingStyle;
GUIStyle BodyStyle
{
get { return m_BodyStyle; }
}
[SerializeField]
GUIStyle m_BodyStyle;
GUIStyle ButtonStyle
{
get { return m_ButtonStyle; }
}
[SerializeField]
GUIStyle m_ButtonStyle;
void Init()
{
if (m_Initialized)
return;
m_BodyStyle = new GUIStyle(EditorStyles.label);
m_BodyStyle.wordWrap = true;
m_BodyStyle.fontSize = 14;
m_BodyStyle.richText = true;
m_TitleStyle = new GUIStyle(m_BodyStyle);
m_TitleStyle.fontSize = 26;
m_HeadingStyle = new GUIStyle(m_BodyStyle);
m_HeadingStyle.fontStyle = FontStyle.Bold;
m_HeadingStyle.fontSize = 18;
m_LinkStyle = new GUIStyle(m_BodyStyle);
m_LinkStyle.wordWrap = false;
// Match selection color which works nicely for both light and dark skins
m_LinkStyle.normal.textColor = new Color(0x00 / 255f, 0x78 / 255f, 0xDA / 255f, 1f);
m_LinkStyle.stretchWidth = false;
m_ButtonStyle = new GUIStyle(EditorStyles.miniButton);
m_ButtonStyle.fontStyle = FontStyle.Bold;
m_Initialized = true;
}
bool LinkLabel(GUIContent label, params GUILayoutOption[] options)
{
var position = GUILayoutUtility.GetRect(label, LinkStyle, options);
Handles.BeginGUI();
Handles.color = LinkStyle.normal.textColor;
Handles.DrawLine(new Vector3(position.xMin, position.yMax), new Vector3(position.xMax, position.yMax));
Handles.color = Color.white;
Handles.EndGUI();
EditorGUIUtility.AddCursorRect(position, MouseCursor.Link);
return GUI.Button(position, label, LinkStyle);
}
}

View File

@@ -1,12 +0,0 @@
fileFormatVersion: 2
guid: 476cc7d7cd9874016adc216baab94a0a
timeCreated: 1484146680
licenseType: Store
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,16 +0,0 @@
using System;
using UnityEngine;
public class Readme : ScriptableObject
{
public Texture2D icon;
public string title;
public Section[] sections;
public bool loadedLayout;
[Serializable]
public class Section
{
public string heading, text, linkText, url;
}
}

View File

@@ -1,12 +0,0 @@
fileFormatVersion: 2
guid: fcf7219bab7fe46a1ad266029b2fee19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences:
- icon: {instanceID: 0}
executionOrder: 0
icon: {fileID: 2800000, guid: a186f8a87ca4f4d3aa864638ad5dfb65, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,52 @@
const fs = require("fs");
const path = require("path");
const DATA_FILE = path.join(__dirname, "../../data/chat.json");
const MAX_MESSAGES = 200;
let _messages = [];
let _nextId = 1;
function _load() {
try {
if (fs.existsSync(DATA_FILE)) {
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
_messages = data.messages || [];
_nextId = data.nextId || (_messages.length + 1);
}
} catch (e) {
console.warn("[Chat] Failed to load chat.json:", e.message);
}
}
function _save() {
try {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify({ messages: _messages, nextId: _nextId }, null, 2));
} catch (e) {
console.warn("[Chat] Failed to save chat.json:", e.message);
}
}
function push(name, text) {
if (!name || !text || typeof name !== "string" || typeof text !== "string") return null;
name = name.slice(0, 32);
text = text.slice(0, 200);
if (text.trim().length === 0) return null;
const msg = { id: _nextId++, timestamp: Date.now(), name, text: text.trim() };
_messages.push(msg);
if (_messages.length > MAX_MESSAGES) _messages.splice(0, _messages.length - MAX_MESSAGES);
_save();
return msg;
}
function getHistory(since) {
const ts = Number(since) || 0;
return ts === 0 ? _messages.slice(-50) : _messages.filter((m) => m.timestamp > ts);
}
_load();
console.log(`[Chat] Loaded ${_messages.length} message(s)`);
module.exports = { push, getHistory };

View File

@@ -1,27 +1,115 @@
const cors = require('cors');
const { Server } = require('@colyseus/core');
const { Server, matchMaker } = require('@colyseus/core');
const { WebSocketTransport } = require('@colyseus/ws-transport');
const { ArenaRoom } = require('./rooms/ArenaRoom');
const Stats = require('./stats/StatsManager');
const Chat = require('./chat/ChatManager');
const { z } = require('zod');
const PORT = process.env.PORT || 2567;
// Colyseus 0.17 express callback receives the transport's internal Express app
const statsUpdateSchema = z.object({
name: z.string().min(1).max(32),
stats: z.object({
totalDistance: z.number().optional(),
totalJumps: z.number().optional(),
maxSpeed: z.number().optional(),
bestRaceTime: z.number().optional(),
racesPlayed: z.number().optional(),
qualifications: z.number().optional(),
eliminations: z.number().optional(),
checkpointsTotal: z.number().optional(),
bumpsGiven: z.number().optional(),
totalPlaytime: z.number().optional(),
}),
});
const chatSendSchema = z.object({
name: z.string().min(1).max(32),
text: z.string().min(1).max(200),
});
let _gameServer;
const gameServer = new Server({
transport: new WebSocketTransport(),
express: (app) => {
app.use(cors());
app.use(require('express').json());
app.get('/health', (_req, res) => {
res.json({ service: 'game', status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/', (_req, res) => {
res.send('🎮 Game server running');
app.get('/', (_req, res) => res.send('🎮 Game server running'));
// ── Stats ────────────────────────────────────────────────────────────
app.get('/stats', (_req, res) => {
res.json(Stats.getAll());
});
app.get('/stats/leaderboard/:key', (req, res) => {
const board = Stats.getLeaderboard(req.params.key);
if (!board) return res.status(400).json({ error: 'invalid key' });
res.json(board);
});
app.post('/stats/update', (req, res) => {
const parsed = statsUpdateSchema.safeParse(req.body);
if (!parsed.success) {
console.warn('[Stats] Bad update request:', JSON.stringify(parsed.error.issues));
return res.status(400).json({ error: parsed.error.issues });
}
const ok = Stats.update(parsed.data.name, parsed.data.stats);
console.log(`[Stats] Update for "${parsed.data.name}": ok=${ok}`, JSON.stringify(parsed.data.stats));
res.json({ ok });
});
// ── Rooms ────────────────────────────────────────────────────────────
app.get('/rooms', async (_req, res) => {
try {
const rooms = await matchMaker.query({ name: 'arena' });
res.json(rooms.map(r => ({
roomId: r.roomId,
clients: r.clients,
maxClients: r.maxClients,
metadata: r.metadata || {},
})));
} catch (_) {
res.json([]);
}
});
// ── Chat ─────────────────────────────────────────────────────────────
app.get('/chat/history', (req, res) => {
res.json(Chat.getHistory(req.query.since));
});
app.post('/chat/send', (req, res) => {
const parsed = chatSendSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues });
const msg = Chat.push(parsed.data.name, parsed.data.text);
if (!msg) return res.status(429).json({ error: 'empty or invalid message' });
// Broadcast to all active Colyseus rooms
if (_gameServer) {
try {
const rooms = _gameServer.matchMaker?.rooms;
if (rooms) {
for (const room of rooms.values()) {
room.broadcast('chat', msg);
}
}
} catch (_) {}
}
res.json(msg);
});
},
});
// Define rooms
_gameServer = gameServer;
gameServer.define('arena', ArenaRoom);
console.log('✅ ArenaRoom registered');

View File

@@ -1,16 +1,12 @@
const { Room } = require("@colyseus/core");
const { GameState, Player } = require("../schema/GameState");
const Chat = require("../chat/ChatManager");
const ROUND_MODES = ["race", "survival", "teams"];
const LOBBY_TIMEOUT = 30; // seconds before auto-start
const LOBBY_TIMEOUT = 30;
const COUNTDOWN_DURATION = 3;
const ROUND_END_DURATION = 5;
const RACE_TIMEOUT = 180; // 3 min
const SURVIVAL_START_DELAY = 20; // seconds before deathzone rises
const SURVIVAL_RISE_RATE = 0.3; // units/sec
const SURVIVAL_MAX_Y = 15;
const TEAMS_DURATION = 90;
const QUALIFY_RATIO = 0.6; // top 60% qualify in race
const QUALIFY_RATIO = 0.6;
class ArenaRoom extends Room {
maxClients = 20;
@@ -18,12 +14,10 @@ class ArenaRoom extends Room {
onCreate(options) {
this.setState(new GameState());
this.setPatchRate(16); // ~62.5 Hz
this.setMetadata({ name: options?.roomName || ('Salle #' + this.roomId.substring(0, 6)) });
this._phaseTimer = null;
this._survivalInterval = null;
this._teamInterval = null;
this._lobbyTimer = null;
this._inZonePlayers = new Set(); // sessionIds currently in zone
console.log(`[ArenaRoom] Room ${this.roomId} created`);
@@ -54,36 +48,25 @@ class ArenaRoom extends Room {
this._checkAllReady();
});
this.onMessage("chat", (client, data) => {
const player = this.state.players.get(client.sessionId);
if (!player || !data.text) return;
const msg = Chat.push(player.name, data.text);
if (msg) this.broadcast("chat", msg);
});
this.onMessage("checkpointReached", (client, data) => {
if (this.state.phase !== "playing" || this.state.gameMode !== "race") return;
if (this.state.phase !== "playing") return;
const player = this.state.players.get(client.sessionId);
if (!player || player.isEliminated || player.isQualified) return;
const expected = player.checkpointIndex;
if (data.index !== expected) return; // must hit in order
if (data.index !== expected) return;
player.checkpointIndex = data.index + 1;
// The last checkpoint (index 4 = finish) qualifies the player
// CheckpointSystem sends index after increment, so finish = totalCheckpoints
const TOTAL_CHECKPOINTS = 5;
if (player.checkpointIndex >= TOTAL_CHECKPOINTS) {
this._qualifyPlayer(client.sessionId, "finish");
}
});
this.onMessage("deathZoneHit", (client) => {
if (this.state.phase !== "playing" || this.state.gameMode !== "survival") return;
this._eliminatePlayer(client.sessionId, "deathzone");
});
this.onMessage("inZone", (client, data) => {
if (this.state.phase !== "playing" || this.state.gameMode !== "teams") return;
const player = this.state.players.get(client.sessionId);
if (!player || player.isEliminated) return;
if (data.inZone) {
this._inZonePlayers.add(client.sessionId);
} else {
this._inZonePlayers.delete(client.sessionId);
}
});
}
onJoin(client, options) {
@@ -101,7 +84,6 @@ class ArenaRoom extends Room {
this.state.players.set(client.sessionId, player);
this._updatePlayersAlive();
// Auto-start lobby timer on first player
if (this.state.players.size === 1 && this.state.phase === "lobby") {
this._startLobbyTimer();
}
@@ -109,7 +91,6 @@ class ArenaRoom extends Room {
onLeave(client, consented) {
console.log(`[ArenaRoom] ${client.sessionId} left`);
this._inZonePlayers.delete(client.sessionId);
this.state.players.delete(client.sessionId);
this._updatePlayersAlive();
if (this.state.phase === "playing") {
@@ -159,29 +140,17 @@ class ArenaRoom extends Room {
}
_startPlaying() {
const modeIndex = (this.state.roundNumber - 1) % ROUND_MODES.length;
this.state.gameMode = ROUND_MODES[modeIndex];
this.state.gameMode = "race";
this.state.phase = "playing";
this.state.countdown = 0;
// Reset player state for new round
let teamToggle = 0;
this.state.players.forEach((p, id) => {
this.state.players.forEach((p) => {
p.isEliminated = false;
p.isQualified = false;
p.isReady = false;
p.checkpointIndex = 0;
if (this.state.gameMode === "teams") {
p.team = (teamToggle++ % 2 === 0) ? 1 : 2;
} else {
p.team = 0;
}
});
this.state.deathZoneY = -50;
this.state.teamScoreRed = 0;
this.state.teamScoreBlue = 0;
this._inZonePlayers.clear();
this._updatePlayersAlive();
this.broadcast("roundStart", {
@@ -190,16 +159,8 @@ class ArenaRoom extends Room {
totalRounds: this.state.totalRounds,
});
console.log(`[ArenaRoom] Round ${this.state.roundNumber} started (mode: ${this.state.gameMode})`);
console.log(`[ArenaRoom] Round ${this.state.roundNumber} started (race)`);
if (this.state.gameMode === "race") {
this._phaseTimer = setTimeout(() => this._endRaceTimeout(), RACE_TIMEOUT * 1000);
} else if (this.state.gameMode === "survival") {
this._phaseTimer = setTimeout(() => this._startSurvivalRise(), SURVIVAL_START_DELAY * 1000);
} else if (this.state.gameMode === "teams") {
this._startTeamsScoring();
this._phaseTimer = setTimeout(() => this._endTeamsRound(), TEAMS_DURATION * 1000);
}
}
_endRound() {
@@ -209,7 +170,6 @@ class ArenaRoom extends Room {
this.broadcast("roundEnd", { round: this.state.roundNumber });
console.log(`[ArenaRoom] Round ${this.state.roundNumber} ended`);
// Check if all rounds done
if (this.state.roundNumber >= this.state.totalRounds) {
this._phaseTimer = setTimeout(() => this._endGame(), ROUND_END_DURATION * 1000);
} else {
@@ -220,13 +180,10 @@ class ArenaRoom extends Room {
_nextRound() {
this.state.roundNumber += 1;
this.state.phase = "lobby";
this.state.playersAlive = 0;
this.state.players.forEach((p) => {
if (!p.isEliminated) {
p.isReady = false;
const spawn = this._findSpawnPosition();
p.x = spawn.x; p.y = spawn.y; p.z = spawn.z;
}
p.isReady = false;
const spawn = this._findSpawnPosition();
p.x = spawn.x; p.y = spawn.y; p.z = spawn.z;
});
this._updatePlayersAlive();
this._lobbyTimer = null;
@@ -236,7 +193,6 @@ class ArenaRoom extends Room {
_endGame() {
this.state.phase = "gameEnd";
// Find winner: last qualified player, or player with most checkpoints
let winner = "";
let best = -1;
this.state.players.forEach((p) => {
@@ -248,61 +204,6 @@ class ArenaRoom extends Room {
console.log(`[ArenaRoom] Game over — winner: ${winner}`);
}
// ─── Race mode ──────────────────────────────────────────────────────
_endRaceTimeout() {
// Eliminate anyone who hasn't qualified
this.state.players.forEach((p, id) => {
if (!p.isQualified && !p.isEliminated) {
this._eliminatePlayer(id, "timeout");
}
});
this._endRound();
}
// ─── Survival mode ──────────────────────────────────────────────────
_startSurvivalRise() {
console.log(`[ArenaRoom] DeathZone starts rising`);
this._survivalInterval = setInterval(() => {
this.state.deathZoneY += SURVIVAL_RISE_RATE * (16 / 1000);
if (this.state.deathZoneY > SURVIVAL_MAX_Y) {
this.state.deathZoneY = SURVIVAL_MAX_Y;
}
}, 16);
}
// ─── Teams mode ─────────────────────────────────────────────────────
_startTeamsScoring() {
this._teamInterval = setInterval(() => {
let redInZone = 0;
let blueInZone = 0;
this._inZonePlayers.forEach((id) => {
const p = this.state.players.get(id);
if (!p || p.isEliminated) return;
if (p.team === 1) redInZone++;
else if (p.team === 2) blueInZone++;
});
if (redInZone > blueInZone) this.state.teamScoreRed = Math.min(this.state.teamScoreRed + 1, 32767);
else if (blueInZone > redInZone) this.state.teamScoreBlue = Math.min(this.state.teamScoreBlue + 1, 32767);
}, 1000);
}
_endTeamsRound() {
// Eliminate losing team
const redWins = this.state.teamScoreRed >= this.state.teamScoreBlue;
const losingTeam = redWins ? 2 : 1;
this.state.players.forEach((p, id) => {
if (p.team === losingTeam && !p.isEliminated) {
this._eliminatePlayer(id, "teams_lost");
} else if (!p.isEliminated) {
this._qualifyPlayer(id, "teams_won");
}
});
this._endRound();
}
// ─── Elimination helpers ─────────────────────────────────────────────
_eliminatePlayer(sessionId, reason) {
@@ -323,45 +224,23 @@ class ArenaRoom extends Room {
this.broadcast("qualified", { sessionId, name: player.name });
console.log(`[ArenaRoom] ${player.name} (${sessionId}) qualified: ${reason}`);
if (this.state.gameMode === "race") {
const aliveCount = this._getAliveCount();
const totalActive = this._getActiveCount();
const qualifiedCount = this._getQualifiedCount();
// Eliminate once qualify_ratio reached
const toQualify = Math.ceil(totalActive * QUALIFY_RATIO);
if (qualifiedCount >= toQualify) {
this.state.players.forEach((p, id) => {
if (!p.isQualified && !p.isEliminated) {
this._eliminatePlayer(id, "too_slow");
}
});
this._endRound();
}
} else if (this.state.gameMode === "survival") {
// In survival: only 1 qualifies (last one), rest get eliminated by zone
this._checkRoundEndCondition();
const totalActive = this._getActiveCount();
const qualifiedCount = this._getQualifiedCount();
const toQualify = Math.ceil(totalActive * QUALIFY_RATIO);
if (qualifiedCount >= toQualify) {
this.state.players.forEach((p, id) => {
if (!p.isQualified && !p.isEliminated) {
this._eliminatePlayer(id, "too_slow");
}
});
this._endRound();
}
}
_checkRoundEndCondition() {
if (this.state.phase !== "playing") return;
const alive = this._getAliveCount();
const qualified = this._getQualifiedCount();
const total = this._getActiveCount();
if (this.state.gameMode === "survival") {
if (alive <= 1) {
// Qualify the last survivor
this.state.players.forEach((p, id) => {
if (!p.isEliminated && !p.isQualified) {
this._qualifyPlayer(id, "last_survivor");
}
});
this._endRound();
}
} else if (alive === 0 || alive + qualified >= total) {
this._endRound();
}
if (alive === 0) this._endRound();
}
_getAliveCount() {
@@ -377,7 +256,9 @@ class ArenaRoom extends Room {
}
_getActiveCount() {
return this.state.players.size;
let n = 0;
this.state.players.forEach((p) => { if (!p.isEliminated) n++; });
return n;
}
_updatePlayersAlive() {
@@ -387,8 +268,6 @@ class ArenaRoom extends Room {
_clearAllTimers() {
if (this._phaseTimer) { clearTimeout(this._phaseTimer); this._phaseTimer = null; }
if (this._lobbyTimer) { clearTimeout(this._lobbyTimer); this._lobbyTimer = null; }
if (this._survivalInterval) { clearInterval(this._survivalInterval); this._survivalInterval = null; }
if (this._teamInterval) { clearInterval(this._teamInterval); this._teamInterval = null; }
}
// ─── Spawn helper ────────────────────────────────────────────────────

View File

@@ -3,88 +3,58 @@ const { Schema, MapSchema, defineTypes } = require("@colyseus/schema");
class Player extends Schema {
constructor() {
super();
this.x = 0;
this.y = 5;
this.z = 0;
this.vx = 0;
this.vy = 0;
this.vz = 0;
this.rx = 0;
this.ry = 0;
this.rz = 0;
this.rw = 1;
this.x = 0; this.y = 5; this.z = 0;
this.vx = 0; this.vy = 0; this.vz = 0;
this.rx = 0; this.ry = 0; this.rz = 0; this.rw = 1;
this.t = 0;
this.name = "";
this.colorR = 1;
this.colorG = 1;
this.colorB = 1;
this.avx = 0;
this.avy = 0;
this.avz = 0;
// Game state
this.colorR = 1; this.colorG = 1; this.colorB = 1;
this.avx = 0; this.avy = 0; this.avz = 0;
this.isEliminated = false;
this.isQualified = false;
this.isReady = false;
this.team = 0;
this.isQualified = false;
this.isReady = false;
this.checkpointIndex = 0;
}
}
// Field order must match NetworkSchema.cs [Type(N)] indices exactly
defineTypes(Player, {
x: "float32",
y: "float32",
z: "float32",
vx: "float32",
vy: "float32",
vz: "float32",
rx: "float32",
ry: "float32",
rz: "float32",
rw: "float32",
t: "float64",
name: "string",
colorR: "float32",
colorG: "float32",
colorB: "float32",
avx: "float32",
avy: "float32",
avz: "float32",
isEliminated: "boolean",
isQualified: "boolean",
isReady: "boolean",
team: "int8",
checkpointIndex: "int8",
x: "float32", y: "float32", z: "float32", // 0-2
vx: "float32", vy: "float32", vz: "float32", // 3-5
rx: "float32", ry: "float32", rz: "float32", rw: "float32", // 6-9
t: "float64", // 10
name: "string", // 11
colorR: "float32", colorG: "float32", colorB: "float32", // 12-14
avx: "float32", avy: "float32", avz: "float32", // 15-17
isEliminated: "boolean", // 18
isQualified: "boolean", // 19
isReady: "boolean", // 20
checkpointIndex: "int8", // 21
});
class GameState extends Schema {
constructor() {
super();
this.players = new MapSchema();
this.phase = "lobby";
this.countdown = 0;
this.roundNumber = 1;
this.totalRounds = 3;
this.players = new MapSchema();
this.phase = "lobby";
this.countdown = 0;
this.roundNumber = 1;
this.totalRounds = 3;
this.playersAlive = 0;
this.gameMode = "race";
this.deathZoneY = -50;
this.teamScoreRed = 0;
this.teamScoreBlue = 0;
this.winnerName = "";
this.gameMode = "race";
this.winnerName = "";
}
}
defineTypes(GameState, {
players: { map: Player },
phase: "string",
countdown: "float32",
roundNumber: "int8",
totalRounds: "int8",
playersAlive: "int8",
gameMode: "string",
deathZoneY: "float32",
teamScoreRed: "int16",
teamScoreBlue: "int16",
winnerName: "string",
players: { map: Player }, // 0
phase: "string", // 1
countdown: "float32", // 2
roundNumber: "int8", // 3
totalRounds: "int8", // 4
playersAlive: "int8", // 5
gameMode: "string", // 6
winnerName: "string", // 7
});
module.exports = { GameState, Player };

View File

@@ -0,0 +1,101 @@
const fs = require("fs");
const path = require("path");
const DATA_FILE = path.join(__dirname, "../../data/stats.json");
const VALID_KEYS = [
"totalDistance", "totalJumps", "maxSpeed", "bestRaceTime",
"racesPlayed", "qualifications", "eliminations",
"checkpointsTotal", "bumpsGiven", "totalPlaytime",
];
let _stats = {};
const _lastUpdate = new Map(); // name → timestamp, for rate-limiting
function _load() {
try {
if (fs.existsSync(DATA_FILE)) {
_stats = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
}
} catch (e) {
console.warn("[Stats] Failed to load stats.json, starting fresh:", e.message);
_stats = {};
}
}
function _save() {
try {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(_stats, null, 2));
} catch (e) {
console.warn("[Stats] Failed to save stats.json:", e.message);
}
}
function _defaults() {
return {
totalDistance: 0,
totalJumps: 0,
maxSpeed: 0,
bestRaceTime: null,
racesPlayed: 0,
qualifications: 0,
eliminations: 0,
checkpointsTotal: 0,
bumpsGiven: 0,
totalPlaytime: 0,
};
}
function update(name, delta) {
if (!name || typeof name !== "string" || name.length > 32) return false;
const now = Date.now();
const last = _lastUpdate.get(name) || 0;
if (now - last < 5000) return false; // rate-limit: 1 update per 5s per player
_lastUpdate.set(name, now);
if (!_stats[name]) _stats[name] = _defaults();
const p = _stats[name];
for (const key of VALID_KEYS) {
if (delta[key] === undefined) continue;
const val = Number(delta[key]);
if (isNaN(val)) continue;
if (key === "maxSpeed") {
p.maxSpeed = Math.max(p.maxSpeed, val);
} else if (key === "bestRaceTime") {
if (val > 0 && (p.bestRaceTime === null || val < p.bestRaceTime)) {
p.bestRaceTime = val;
}
} else {
p[key] = (p[key] || 0) + val;
}
}
_save();
return true;
}
function getAll() {
return Object.entries(_stats).map(([name, s]) => ({ name, ...s }));
}
function getLeaderboard(key) {
if (!VALID_KEYS.includes(key)) return [];
return Object.entries(_stats)
.map(([name, s]) => ({ name, value: s[key] ?? 0 }))
.filter((e) => e.value !== null && e.value > 0)
.sort((a, b) => {
// bestRaceTime: lower is better
if (key === "bestRaceTime") return a.value - b.value;
return b.value - a.value;
})
.slice(0, 10);
}
_load();
console.log(`[Stats] Loaded ${Object.keys(_stats).length} player(s)`);
module.exports = { update, getAll, getLeaderboard, VALID_KEYS };