feat: add frontend as flat files (was submodule)

This commit is contained in:
2026-05-15 09:13:20 +02:00
parent 679929cffe
commit ce1972c6fa
23 changed files with 3677 additions and 1 deletions

Submodule frontend deleted from 6803f05f5e

6
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
*.log
public/unity-build/TemplateData/

66
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
# --- Build stage ---
FROM node:18-alpine AS build
# Environment: 'dev' or 'prod' (controls theming)
ARG VITE_ENV=prod
ENV VITE_ENV=$VITE_ENV
# Game server WebSocket URL (Colyseus)
ARG VITE_GAME_SERVER_URL
ENV VITE_GAME_SERVER_URL=$VITE_GAME_SERVER_URL
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Production stage ---
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Copy Unity build if present
COPY --from=build /app/public/unity-build /usr/share/nginx/html/unity-build
# Nginx config for SPA + gzip precompressed Unity files
RUN cat > /etc/nginx/conf.d/default.conf << 'EOF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript application/wasm;
location /unity-build/Build/ {
types {
application/javascript js;
application/wasm wasm;
application/octet-stream data;
}
add_header Cache-Control "no-cache, must-revalidate";
gzip on;
gzip_min_length 1000;
gzip_types application/javascript application/wasm application/octet-stream;
}
# Aggressive cache for hashed Vite assets (NOT Unity build files)
location ~* ^(?!/unity-build/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}
EOF
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# ROLL'D — Frontend
Client web pour le jeu ROLL'D. Héberge le build Unity WebGL dans une interface moderne.
## Stack
- **React 18** + **Vite 5** — build rapide, HMR
- **Tailwind CSS 3** — styling utility-first
- **Unity WebGL Loader** — intégration du build Unity
## Quickstart
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build → dist/
```
## Unity WebGL Build
Placer le build Unity dans `public/unity-build/` :
```
public/unity-build/
├── Build/
│ ├── build.data.gz
│ ├── build.framework.js.gz
│ ├── build.loader.js
│ └── build.wasm.gz
└── TemplateData/ (optionnel)
```
## Docker
```bash
docker build -t rolld-frontend .
docker run -p 80:80 rolld-frontend
```
## Structure
```
├── public/
│ ├── unity-build/ # Build WebGL (non versionné)
│ └── favicon.svg
├── src/
│ ├── components/ # Composants React
│ ├── assets/ # Images, fonts
│ ├── App.jsx
│ └── main.jsx
├── index.html
├── tailwind.config.js
├── vite.config.js
└── Dockerfile
```

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="fr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ROLL'D — Un MMO de billes multijoueur avec des mécaniques de gel uniques. Jouez directement dans votre navigateur." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>ROLL'D — Marble MMO</title>
</head>
<body class="bg-[#0a0a0f] text-[#e8e8f0] antialiased overflow-x-hidden">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2641
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "rolld-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-unity-webgl": "^9.5.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6c5ce7"/>
<stop offset="100%" stop-color="#9b59b6"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#g)"/>
<circle cx="50" cy="50" r="38" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="2"/>
<circle cx="38" cy="38" r="8" fill="rgba(255,255,255,0.25)"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -0,0 +1,2 @@
# Unity WebGL build goes here
Place your Unity WebGL build files in a `Build/` subfolder.

View File

@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Unity Web Player | BallProject</title>
<link rel="shortcut icon" href="TemplateData/favicon.ico">
<link rel="stylesheet" href="TemplateData/style.css">
</head>
<body>
<div id="unity-container" class="unity-desktop">
<canvas id="unity-canvas" width=960 height=600 tabindex="-1"></canvas>
<div id="unity-loading-bar">
<div id="unity-logo"></div>
<div id="unity-progress-bar-empty">
<div id="unity-progress-bar-full"></div>
</div>
</div>
<div id="unity-warning"> </div>
<div id="unity-footer">
<div id="unity-logo-title-footer"></div>
<div id="unity-fullscreen-button"></div>
<div id="unity-build-title">BallProject</div>
</div>
</div>
<script>
var canvas = document.querySelector("#unity-canvas");
// Shows a temporary message banner/ribbon for a few seconds, or
// a permanent error message on top of the canvas if type=='error'.
// If type=='warning', a yellow highlight color is used.
// Modify or remove this function to customize the visually presented
// way that non-critical warnings and error messages are presented to the
// user.
function unityShowBanner(msg, type) {
var warningBanner = document.querySelector("#unity-warning");
function updateBannerVisibility() {
warningBanner.style.display = warningBanner.children.length ? 'block' : 'none';
}
var div = document.createElement('div');
div.innerHTML = msg;
warningBanner.appendChild(div);
if (type == 'error') div.style = 'background: red; padding: 10px;';
else {
if (type == 'warning') div.style = 'background: yellow; padding: 10px;';
setTimeout(function() {
warningBanner.removeChild(div);
updateBannerVisibility();
}, 5000);
}
updateBannerVisibility();
}
var buildUrl = "Build";
var loaderUrl = buildUrl + "/nouveau_build.loader.js";
var config = {
arguments: [],
dataUrl: buildUrl + "/nouveau_build.data",
frameworkUrl: buildUrl + "/nouveau_build.framework.js",
codeUrl: buildUrl + "/nouveau_build.wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "BallProject",
productVersion: "0.1.0",
showBanner: unityShowBanner,
// errorHandler: function(err, url, line) {
// alert("error " + err + " occurred at line " + line);
// // Return 'true' if you handled this error and don't want Unity
// // to process it further, 'false' otherwise.
// return true;
// },
};
// By default, Unity keeps WebGL canvas render target size matched with
// the DOM size of the canvas element (scaled by window.devicePixelRatio)
// Set this to false if you want to decouple this synchronization from
// happening inside the engine, and you would instead like to size up
// the canvas DOM size and WebGL render target sizes yourself.
// config.matchWebGLToCanvasSize = false;
// If you would like all file writes inside Unity Application.persistentDataPath
// directory to automatically persist so that the contents are remembered when
// the user revisits the site the next time, uncomment the following line:
// config.autoSyncPersistentDataPath = true;
// This autosyncing is currently not the default behavior to avoid regressing
// existing user projects that might rely on the earlier manual
// JS_FileSystem_Sync() behavior, but in future Unity version, this will be
// expected to change.
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
// Mobile device style: fill the whole browser client area with the game canvas:
var meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes';
document.getElementsByTagName('head')[0].appendChild(meta);
document.querySelector("#unity-container").className = "unity-mobile";
canvas.className = "unity-mobile";
// To lower canvas resolution on mobile devices to gain some
// performance, uncomment the following line:
// config.devicePixelRatio = 1;
} else {
// Desktop style: Render the game canvas in a window that can be maximized to fullscreen:
canvas.style.width = "960px";
canvas.style.height = "600px";
}
document.querySelector("#unity-loading-bar").style.display = "block";
var script = document.createElement("script");
script.src = loaderUrl;
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {
document.querySelector("#unity-progress-bar-full").style.width = 100 * progress + "%";
}).then((unityInstance) => {
document.querySelector("#unity-loading-bar").style.display = "none";
document.querySelector("#unity-fullscreen-button").onclick = () => {
unityInstance.SetFullscreen(1);
};
}).catch((message) => {
alert(message);
});
};
document.body.appendChild(script);
</script>
</body>
</html>

30
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import { IS_DEV } from './env'
import DevBanner from './components/DevBanner'
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'
function App() {
const [isPlaying, setIsPlaying] = useState(false)
if (isPlaying) {
return <GameCanvas onBack={() => setIsPlaying(false)} />
}
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 />
</div>
)
}
export default App

View File

@@ -0,0 +1,26 @@
import { IS_DEV, theme } from '../env'
export default function DevBanner() {
if (!IS_DEV) return null
return (
<div
className="fixed top-0 left-0 right-0 z-[100] flex items-center justify-center gap-2 py-1.5 text-xs font-mono tracking-wider text-black/80 select-none"
style={{
background: `repeating-linear-gradient(
-45deg,
${theme.accent},
${theme.accent} 10px,
${theme.gradientTo} 10px,
${theme.gradientTo} 20px
)`,
}}
>
<span>{theme.badge}</span>
<span className="font-bold uppercase">Environnement de développement</span>
<span></span>
<span className="opacity-70">Ne pas utiliser en conditions réelles</span>
<span>{theme.badge}</span>
</div>
)
}

View File

@@ -0,0 +1,27 @@
export default function Footer() {
return (
<footer className="py-8 px-4 border-t border-rolld-border/50">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-rolld-muted">
<div className="flex items-center gap-2">
<span className="font-bold text-rolld-text">ROLL'D</span>
<span>·</span>
<span>Marble MMO</span>
</div>
<div className="flex items-center gap-4 text-xs">
<span>Unity · React · Colyseus</span>
<span>·</span>
<span className="text-rolld-accent/60">v0.1.2-dev</span>
<span>·</span>
<a
href="https://git.kerboul.me/kerboul/rolld_frontend"
target="_blank"
rel="noopener noreferrer"
className="hover:text-rolld-accent-light transition-colors"
>
Source
</a>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,189 @@
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}`
// 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)
const [isLoaded, setIsLoaded] = useState(false)
const [hasUnityBuild, setHasUnityBuild] = useState(null) // null = checking, true/false = result
const [error, setError] = useState(null)
useEffect(() => {
// Check if Unity build exists
fetch(LOADER_URL, { method: 'HEAD' })
.then((res) => {
if (res.ok) {
setHasUnityBuild(true)
loadUnity()
} else {
setHasUnityBuild(false)
}
})
.catch(() => {
setHasUnityBuild(false)
})
}, [])
const loadUnity = useCallback(() => {
// Dynamically load Unity loader
const script = document.createElement('script')
script.src = LOADER_URL
script.onload = () => {
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}`,
streamingAssetsUrl: '/unity-build/StreamingAssets',
companyName: 'ROLLD',
productName: 'ROLLD',
productVersion: '0.1',
}, (progress) => {
setLoadingProgress(Math.round(progress * 100))
}).then((instance) => {
setIsLoaded(true)
window.__unityInstance = instance
// Patch requestPointerLock to catch SecurityError (user exits lock before promise resolves)
const canvas = document.getElementById('unity-canvas')
if (canvas) {
const origLock = canvas.requestPointerLock.bind(canvas)
canvas.requestPointerLock = function (...args) {
try {
const result = origLock(...args)
if (result && typeof result.catch === 'function') {
return result.catch((e) => {
if (e.name === 'SecurityError') {
console.warn('[ROLLD] Pointer lock interrupted (SecurityError) — ignored')
} else {
throw e
}
})
}
return result
} catch (e) {
if (e.name === 'SecurityError') {
console.warn('[ROLLD] Pointer lock sync error — ignored')
} else {
throw e
}
}
}
}
// 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)
}).catch((err) => {
setError(err.message)
})
}
}
script.onerror = () => setError('Failed to load Unity loader')
document.body.appendChild(script)
}, [])
return (
<div className="fixed inset-0 bg-rolld-bg z-50 flex flex-col">
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-2 bg-rolld-surface/80 backdrop-blur border-b border-rolld-border">
<button
onClick={onBack}
className="flex items-center gap-2 text-rolld-muted hover:text-rolld-text transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Retour
</button>
<span className="text-rolld-accent font-bold tracking-wide text-sm">ROLL'D</span>
<div className="w-16" /> {/* Spacer */}
</div>
{/* Game area */}
<div className="flex-1 relative flex items-center justify-center">
{/* Unity Canvas (hidden until build exists) */}
<canvas
id="unity-canvas"
className={`w-full h-full ${hasUnityBuild && isLoaded ? 'block' : 'hidden'}`}
tabIndex={-1}
/>
{/* Loading state */}
{hasUnityBuild === true && !isLoaded && !error && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6">
<div className="text-4xl font-black">
<span className="text-rolld-text">ROLL</span>
<span className="text-transparent bg-clip-text bg-gradient-to-r from-rolld-accent to-rolld-violet">'D</span>
</div>
<div className="w-64">
<div className="h-2 rounded-full bg-rolld-surface overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-rolld-accent to-rolld-violet transition-all duration-300"
style={{ width: `${loadingProgress}%` }}
/>
</div>
<p className="text-rolld-muted text-sm text-center mt-2">Chargement {loadingProgress}%</p>
</div>
</div>
)}
{/* No build placeholder */}
{hasUnityBuild === false && (
<div className="flex flex-col items-center justify-center gap-6 text-center px-4">
<div className="w-24 h-24 rounded-3xl bg-rolld-surface border border-rolld-border flex items-center justify-center text-5xl animate-float">
🎮
</div>
<div>
<h2 className="text-2xl font-bold text-rolld-text mb-2">Build Unity en attente</h2>
<p className="text-rolld-muted max-w-md leading-relaxed">
Le build WebGL n'est pas encore disponible. Placez les fichiers dans{' '}
<code className="px-2 py-0.5 rounded bg-rolld-surface border border-rolld-border text-rolld-accent-light text-sm font-mono">
public/unity-build/Build/
</code>
</p>
</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>
</div>
</div>
)}
{/* Checking state */}
{hasUnityBuild === null && (
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 border-2 border-rolld-accent border-t-transparent rounded-full animate-spin" />
<p className="text-rolld-muted text-sm">Vérification du build…</p>
</div>
)}
{/* Error state */}
{error && (
<div className="flex flex-col items-center gap-4 text-center px-4">
<div className="text-5xl">⚠️</div>
<h2 className="text-xl font-bold text-red-400">Erreur de chargement</h2>
<p className="text-rolld-muted max-w-md text-sm">{error}</p>
<button
onClick={onBack}
className="px-4 py-2 rounded-lg bg-rolld-surface border border-rolld-border text-rolld-text hover:border-rolld-accent/40 transition-colors text-sm"
>
Retour à l'accueil
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
const gels = [
{
name: 'Gel Orange',
emoji: '🟠',
description: 'Surface de boost — multiplie votre vitesse. Prenez de l\'élan et catapultez-vous vers de nouveaux sommets.',
gradient: 'from-orange-500 to-amber-600',
borderColor: 'border-orange-500/30',
glowColor: 'hover:shadow-orange-500/20',
bgGlow: 'bg-orange-500/5',
},
{
name: 'Gel Violet',
emoji: '🟣',
description: 'Surface sticky — collez aux murs et plafonds. La gravité s\'inverse pour vous plaquer contre la surface.',
gradient: 'from-purple-500 to-violet-600',
borderColor: 'border-purple-500/30',
glowColor: 'hover:shadow-purple-500/20',
bgGlow: 'bg-purple-500/5',
},
{
name: 'Gel Bleu',
emoji: '🔵',
description: 'Surface rebondissante — transformez votre bille en super balle. Plus vous arrivez vite, plus vous rebondissez haut.',
gradient: 'from-blue-500 to-cyan-600',
borderColor: 'border-blue-500/30',
glowColor: 'hover:shadow-blue-500/20',
bgGlow: 'bg-blue-500/5',
},
]
export default function GelShowcase() {
return (
<section id="gels" className="py-24 px-4">
<div className="max-w-6xl mx-auto">
{/* Section header */}
<div className="text-center mb-16">
<h2 className="text-3xl md:text-5xl font-bold text-rolld-text mb-4">
Trois gels, infinies possibilités
</h2>
<p className="text-rolld-muted text-lg max-w-xl mx-auto">
Chaque surface change radicalement votre physique. Maîtrisez-les pour dominer l'arène.
</p>
</div>
{/* Gel cards */}
<div className="grid md:grid-cols-3 gap-6">
{gels.map((gel) => (
<div
key={gel.name}
className={`group relative rounded-2xl border ${gel.borderColor} bg-rolld-surface p-8 transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl ${gel.glowColor}`}
>
{/* Glow effect */}
<div className={`absolute inset-0 rounded-2xl ${gel.bgGlow} opacity-0 group-hover:opacity-100 transition-opacity duration-500`} />
<div className="relative z-10">
{/* Icon */}
<div className="text-5xl mb-4">{gel.emoji}</div>
{/* Title */}
<h3 className={`text-xl font-bold mb-3 bg-gradient-to-r ${gel.gradient} bg-clip-text text-transparent`}>
{gel.name}
</h3>
{/* Description */}
<p className="text-rolld-muted leading-relaxed text-sm">
{gel.description}
</p>
</div>
</div>
))}
</div>
{/* Controls hint */}
<div className="mt-16 glass rounded-2xl p-8 text-center">
<h3 className="text-xl font-semibold text-rolld-text mb-6">Contrôles</h3>
<div className="flex flex-wrap justify-center gap-6">
{[
{ keys: 'ZQSD', label: 'Mouvement' },
{ keys: 'ESPACE', label: 'Sauter (maintenir = +force)' },
{ keys: 'SOURIS', label: 'Caméra' },
{ keys: 'CLIC DROIT', label: 'Libérer le curseur' },
].map((control) => (
<div key={control.keys} className="flex flex-col items-center gap-2">
<kbd className="px-3 py-1.5 rounded-lg bg-rolld-bg border border-rolld-border text-rolld-accent-light font-mono text-sm">
{control.keys}
</kbd>
<span className="text-rolld-muted text-xs">{control.label}</span>
</div>
))}
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,83 @@
import { IS_DEV, theme } from '../env'
export default function Hero({ onPlay }) {
return (
<section className="relative min-h-screen flex flex-col items-center justify-center px-4 overflow-hidden">
{/* Background effects */}
<div className="absolute inset-0 pointer-events-none">
{/* Gradient orbs */}
<div
className="absolute top-1/4 left-1/4 w-[500px] h-[500px] rounded-full blur-[120px] animate-float"
style={{ background: `rgba(${theme.accentRgb}, 0.1)` }}
/>
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] rounded-full bg-rolld-orange/8 blur-[100px] animate-float" style={{ animationDelay: '-3s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[300px] h-[300px] rounded-full bg-rolld-violet/8 blur-[80px]" />
{/* Grid */}
<div className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `linear-gradient(rgba(${theme.accentRgb},0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(${theme.accentRgb},0.5) 1px, transparent 1px)`,
backgroundSize: '60px 60px',
}}
/>
</div>
{/* Content */}
<div className="relative z-10 text-center max-w-4xl mx-auto animate-slide-up">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass text-sm mb-8" style={{ color: theme.accentLight }}>
<span className="w-2 h-2 rounded-full animate-pulse" style={{ background: theme.accent }} />
{IS_DEV ? '🚧 DEV · ' : ''}Marble MMO · Multijoueur temps réel
</div>
{/* Title */}
<h1 className="text-6xl md:text-8xl font-black tracking-tight mb-6">
<span className="text-rolld-text">ROLL</span>
<span
className="text-transparent bg-clip-text"
style={{ backgroundImage: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo}, ${IS_DEV ? '#e67e22' : '#f39c12'})` }}
>'D</span>
</h1>
{/* Subtitle */}
<p className="text-lg md:text-xl text-rolld-muted max-w-2xl mx-auto mb-12 leading-relaxed">
Un monde de billes, de gels et de physique. Roulez sur des surfaces qui boostent,
collent ou font rebondir — et affrontez d'autres joueurs en temps réel.
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<button
onClick={onPlay}
className="group relative px-8 py-4 rounded-2xl text-white font-bold text-lg transition-all duration-300 hover:scale-105"
style={{
backgroundImage: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})`,
boxShadow: `0 0 30px rgba(${theme.accentRgb}, 0.4)`,
}}
>
<span className="relative z-10 flex items-center gap-3">
<svg className="w-6 h-6 group-hover:translate-x-0.5 transition-transform" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
Jouer maintenant
</span>
</button>
<a
href="#gels"
className="px-6 py-3 rounded-xl border border-rolld-border text-rolld-muted hover:text-rolld-text hover:border-rolld-accent/40 transition-all duration-300"
>
Découvrir les mécaniques
</a>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<div className="w-5 h-8 rounded-full border-2 border-rolld-muted/30 flex items-start justify-center p-1">
<div className="w-1 h-2 rounded-full bg-rolld-muted/50" />
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,122 @@
export default function KerboulistanBanner() {
return (
<section className="relative py-20 px-4 overflow-hidden">
{/* Transition gradient — ROLL'D dark to gov gold tint and back */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 bg-gradient-to-b from-rolld-bg via-[#0d0c08] to-rolld-bg" />
{/* Subtle gold fog */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] rounded-full bg-amber-500/[0.04] blur-[100px]" />
{/* Scanlines — bureaucratic CRT vibe */}
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(217,175,78,0.4) 2px, rgba(217,175,78,0.4) 3px)',
}}
/>
</div>
<div className="relative z-10 max-w-3xl mx-auto text-center">
{/* Classification header */}
<div className="inline-flex items-center gap-2 px-3 py-1 rounded border border-amber-700/30 bg-amber-900/10 mb-8">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500/80 animate-pulse" />
<span className="text-[10px] tracking-[0.2em] uppercase text-amber-500/60 font-mono">
Directive n°2026-042 · Programme homologué
</span>
</div>
{/* Government seal / emblem area */}
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-r from-transparent to-amber-600/30" />
<a
href="https://gov.kerboul.me"
target="_blank"
rel="noopener noreferrer"
className="group relative"
>
{/* Seal ring */}
<div className="w-20 h-20 rounded-full border border-amber-600/30 group-hover:border-amber-500/50 flex items-center justify-center transition-all duration-500 group-hover:shadow-[0_0_30px_rgba(217,175,78,0.1)]">
<div className="w-16 h-16 rounded-full border border-amber-700/20 flex items-center justify-center">
<span className="text-2xl grayscale group-hover:grayscale-0 transition-all duration-500">🏛</span>
</div>
</div>
{/* Seal text ring (simulated) */}
<div className="absolute -inset-2 rounded-full border border-dashed border-amber-800/15 group-hover:border-amber-700/25 transition-colors duration-500 animate-[spin_60s_linear_infinite]" />
</a>
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-l from-transparent to-amber-600/30" />
</div>
{/* Title — gov style */}
<h2 className="text-xs tracking-[0.3em] uppercase text-amber-500/50 font-medium mb-2">
République Libre Populaire Démocratique
</h2>
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-2">
<span className="text-amber-100/80">du </span>
<a
href="https://gov.kerboul.me"
target="_blank"
rel="noopener noreferrer"
className="text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-600 hover:from-amber-300 hover:to-amber-500 transition-all duration-300"
>
Kerboulistan
</a>
</h3>
{/* Divider */}
<div className="flex items-center justify-center gap-3 my-6">
<div className="h-px w-12 bg-amber-700/30" />
<span className="text-amber-600/30 text-xs"></span>
<div className="h-px w-12 bg-amber-700/30" />
</div>
{/* Body text — bureaucratic tone */}
<p className="text-rolld-muted/70 text-sm leading-relaxed max-w-lg mx-auto mb-2">
Ce programme vidéoludique a été développé sous l'autorité directe du{' '}
<span className="text-amber-400/60">Couple Présidentiel</span> et financé par le{' '}
<span className="text-amber-400/60">Ministère de la Défense</span> dans le cadre de
la Directive de Divertissement Stratégique.
</p>
<p className="text-rolld-muted/40 text-xs leading-relaxed max-w-md mx-auto mb-8">
Budget alloué : 35 Memes ($K) · Temps de développement : classifié ·
Taux de satisfaction prévu : 420%
</p>
{/* Stamps / tags */}
<div className="flex flex-wrap items-center justify-center gap-3 mb-8">
{[
{ label: 'Homologué RLPDK', icon: '🛡' },
{ label: 'Approuvé FBC', icon: '📡' },
{ label: 'MEMECON 5', icon: '🟢' },
].map((stamp) => (
<div
key={stamp.label}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-amber-800/20 bg-amber-900/5 text-amber-500/40 text-[10px] tracking-[0.15em] uppercase font-mono"
>
<span className="text-xs">{stamp.icon}</span>
{stamp.label}
</div>
))}
</div>
{/* CTA — gov link */}
<a
href="https://gov.kerboul.me"
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-2 px-5 py-2.5 rounded-lg border border-amber-700/25 hover:border-amber-600/40 bg-amber-900/5 hover:bg-amber-900/10 transition-all duration-300"
>
<span className="text-amber-500/50 group-hover:text-amber-400/70 text-xs tracking-[0.1em] uppercase transition-colors duration-300">
Consulter le Gouvernement
</span>
<svg className="w-3 h-3 text-amber-600/40 group-hover:text-amber-500/60 group-hover:translate-x-0.5 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/* Bottom motto */}
<p className="mt-8 text-[9px] tracking-[0.25em] uppercase text-amber-700/25 font-mono">
« Endgame, Combined Arms, et Zougoulag ! »
</p>
</div>
</section>
)
}

31
frontend/src/env.js Normal file
View File

@@ -0,0 +1,31 @@
// Environment configuration
// Set VITE_ENV=dev in Coolify build args for the dev application
// Defaults to 'prod' if not set
export const ENV = import.meta.env.VITE_ENV || 'prod'
export const IS_DEV = ENV === 'dev'
export const IS_PROD = ENV === 'prod'
// Theme overrides per environment
export const envTheme = {
dev: {
accent: '#f39c12', // Orange
accentLight: '#f1c40f', // Yellow-orange
accentRgb: '243, 156, 18',
gradientFrom: '#f39c12',
gradientTo: '#e67e22',
label: 'DEV',
badge: '🚧',
},
prod: {
accent: '#6c5ce7', // Purple (default)
accentLight: '#a29bfe',
accentRgb: '108, 92, 231',
gradientFrom: '#6c5ce7',
gradientTo: '#9b59b6',
label: 'PROD',
badge: '🚀',
},
}
export const theme = envTheme[ENV] || envTheme.prod

43
frontend/src/index.css Normal file
View File

@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--rolld-accent: #6c5ce7;
--rolld-accent-light: #a29bfe;
--rolld-accent-rgb: 108, 92, 231;
}
body {
font-family: 'Inter', system-ui, sans-serif;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0a0a0f;
}
::-webkit-scrollbar-thumb {
background: var(--rolld-accent);
border-radius: 3px;
}
}
@layer components {
.gel-gradient-orange {
background: linear-gradient(135deg, #f39c12, #e67e22);
}
.gel-gradient-violet {
background: linear-gradient(135deg, #9b59b6, #8e44ad);
}
.gel-gradient-blue {
background: linear-gradient(135deg, #3498db, #2980b9);
}
.glass {
background: rgba(18, 18, 26, 0.7);
backdrop-filter: blur(16px);
border: 1px solid rgba(var(--rolld-accent-rgb), 0.15);
}
}

10
frontend/src/main.jsx Normal file
View File

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

View File

@@ -0,0 +1,49 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'rolld': {
bg: '#0a0a0f',
surface: '#12121a',
border: '#1e1e2e',
accent: '#6c5ce7',
'accent-light': '#a29bfe',
orange: '#f39c12',
violet: '#9b59b6',
blue: '#3498db',
text: '#e8e8f0',
muted: '#6b7280',
}
},
fontFamily: {
display: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {
'float': 'float 6s ease-in-out infinite',
'pulse-glow': 'pulseGlow 2s ease-in-out infinite',
'slide-up': 'slideUp 0.8s ease-out',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-20px)' },
},
pulseGlow: {
'0%, 100%': { boxShadow: '0 0 20px rgba(108, 92, 231, 0.3)' },
'50%': { boxShadow: '0 0 40px rgba(108, 92, 231, 0.6)' },
},
slideUp: {
'0%': { opacity: 0, transform: 'translateY(30px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
},
},
},
},
plugins: [],
}

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
build: {
outDir: 'dist',
assetsInlineLimit: 0,
},
})