diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce812f6..cdb24e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Lint + run: npm run lint + + - name: Format + run: npm run format:check + - name: Test run: npm run test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..de102a9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +public/assets +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..da70510 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "trailingComma": "none", + "arrowParens": "always" +} diff --git a/ASSETS-LICENSE.md b/ASSETS-LICENSE.md new file mode 100644 index 0000000..8d8cb75 --- /dev/null +++ b/ASSETS-LICENSE.md @@ -0,0 +1,45 @@ +# Asset license + +Copyright (c) 2026 MateCode. All rights reserved. + +The **source code** of Event Chaos is MIT licensed — see [`LICENSE`](LICENSE). +This document covers the **assets**, which are not. + +## What this covers + +Everything under [`public/assets/`](public/assets) and [`public/`](public), including: + +- Crew portraits, scenario artwork and menu backgrounds +- Stage element illustrations and FX textures +- Icons, the social sharing image and the favicon set +- The art direction described in [`docs/ART_BIBLE.md`](docs/ART_BIBLE.md) + +Sounds are synthesised at runtime by `hooks/useSoundSynth.ts`. That file is code +and falls under the MIT license; the musical and sound design decisions expressed +by it are covered here. + +## Terms + +These assets are licensed under +[Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International](https://creativecommons.org/licenses/by-nc-nd/4.0/) +(CC BY-NC-ND 4.0). + +| You may | You may not | +| -------------------------------------------------------- | -------------------------------------------- | +| Share the assets as they appear here | Use them commercially | +| Study them and reference them in write-ups | Publish modified or derived versions | +| Run and fork the project for personal or educational use | Redistribute them as part of another product | + +Attribution is required in every case: credit **MateCode** and link back to this +repository. + +## In practice + +Fork the repository, read the code, learn from it, change it, run it — all fine, +and the MIT license on the code is a genuine invitation to do so. + +What is not fine is lifting the artwork into another game, an asset pack, a +template for sale, or a training set for a model. + +If you want to use these assets outside those terms, ask: +[matecode.dev](https://matecode.dev). The answer is not automatically no. diff --git a/App.tsx b/App.tsx index 746f7be..388d4e0 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,5 @@ - import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { GameMode, GameState, SystemType, SystemState, GameEventOption } from './types'; +import { GameMode, GameState, SystemType, GameEventOption } from './types'; import { SCENARIOS, TUTORIAL_STEPS, PERMANENT_UPGRADES, WIN_CONDITIONS } from './constants'; import { useGameLogic } from './hooks/useGameLogic'; import { useClientAI } from './hooks/useClientAI'; @@ -35,15 +34,36 @@ import { computeMobileHudInsets } from './utils/mobileHudLayout'; import { useUserSettings, VISUAL_QUALITY_LABEL } from './hooks/useUserSettings'; import { useViewportLayout } from './hooks/useViewportLayout'; import { getVisualQualityProfile } from './utils/visualPerformance'; -import { getCinematicTransitionStyle, getThreatLevel, getThreatRailProfile } from './utils/cinematicFx'; +import { + getCinematicTransitionStyle, + getThreatLevel, + getThreatRailProfile +} from './utils/cinematicFx'; import { getEventImpactStyle } from './utils/impactFx'; import type { EventImpactStyle } from './utils/impactFx'; import { buildRuntimeDiagnosticsSnapshot } from './utils/runtimeDiagnostics'; import { getArtDirectionCssVariables, getScenarioArtDirectionProfile } from './utils/artDirection'; import { getFxTextureCssVariables, getSceneBackgroundAsset } from './utils/artAssets'; -import { getMenuToolbarClasses, getMobileOverlayVisibility, sortEventsByUrgency } from './utils/mobileUiPolicy'; +import { + getMenuToolbarClasses, + getMobileOverlayVisibility, + sortEventsByUrgency +} from './utils/mobileUiPolicy'; import { getHudCinematicClasses } from './utils/uiCinematics'; -import { Activity, DollarSign, Trophy, AlertOctagon, Users, ZapOff, Frown, Pause, RotateCcw, AlertTriangle, Settings, Home } from 'lucide-react'; +import { + Activity, + DollarSign, + Trophy, + AlertOctagon, + Users, + ZapOff, + Frown, + Pause, + RotateCcw, + AlertTriangle, + Settings, + Home +} from 'lucide-react'; interface ActiveCinematicTransition { id: number; @@ -70,11 +90,11 @@ interface ActiveCameraPunch { } const App: React.FC = () => { - const { - gameState, - stats, - systems, - activeEvents, + const { + gameState, + stats, + systems, + activeEvents, activeMinigame, careerData, inventory, @@ -88,12 +108,11 @@ const App: React.FC = () => { startGame, // Shop -> Game togglePause, quitGame, - resolveEvent, + resolveEvent, completeMinigame, setGameState, handleFaderChange, applyComboBonus, - setComboBonusCallback, setEventResolutionCallback, currentScenario, currentGameMode, @@ -102,7 +121,10 @@ const App: React.FC = () => { } = useGameLogic(); // REMOVED: useAIImage hook is no longer needed for 2D visualizer - const { clientMessage, clientMood, clearMessage } = useClientAI(stats, gameState === GameState.PLAYING && !tutorialActive); + const { clientMessage, clientMood, clearMessage } = useClientAI( + stats, + gameState === GameState.PLAYING && !tutorialActive + ); const { playClick, playError, @@ -122,19 +144,21 @@ const App: React.FC = () => { stopBackgroundLoop, updateBackgroundLoop } = useSoundSynth(); - + // Combo System - const { comboState, setBonusCallback } = useComboSystem({ - systems, - isPlaying: gameState === GameState.PLAYING && !tutorialActive + const { comboState, setBonusCallback } = useComboSystem({ + systems, + isPlaying: gameState === GameState.PLAYING && !tutorialActive }); - + const [selectedSystem, setSelectedSystem] = useState(SystemType.SOUND); const [screenShake, setScreenShake] = useState(false); const [explosionTrigger, setExplosionTrigger] = useState(false); const [selectedScenarioId, setSelectedScenarioId] = useState(SCENARIOS[0].id); const [selectedGameMode, setSelectedGameMode] = useState(GameMode.NORMAL); - const [logs, setLogs] = useState<{id: string, text: string, type: 'info'|'error'|'warning'|'success'}[]>([]); + const [logs, setLogs] = useState< + { id: string; text: string; type: 'info' | 'error' | 'warning' | 'success' }[] + >([]); const [showAchievements, setShowAchievements] = useState(false); const [showUpgrades, setShowUpgrades] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -162,7 +186,7 @@ const App: React.FC = () => { const [activeImpactFx, setActiveImpactFx] = useState(null); const [activeFreezePulse, setActiveFreezePulse] = useState(null); const [activeCameraPunch, setActiveCameraPunch] = useState(null); - + const prevEventIdsRef = useRef>(new Set()); const prevGameStateRef = useRef(gameState); const headerRef = useRef(null); @@ -206,9 +230,7 @@ const App: React.FC = () => { clearTimeout(transitionTimeoutRef.current); } transitionTimeoutRef.current = window.setTimeout(() => { - setActiveTransition(current => - current && current.id === transitionId ? null : current - ); + setActiveTransition((current) => (current && current.id === transitionId ? null : current)); transitionTimeoutRef.current = null; }, transitionStyle.durationMs); } @@ -242,7 +264,8 @@ const App: React.FC = () => { }, [isMobileLayout]); useEffect(() => { - if (!isMobileLayout || (gameState !== GameState.PLAYING && gameState !== GameState.PAUSED)) return; + if (!isMobileLayout || (gameState !== GameState.PLAYING && gameState !== GameState.PAUSED)) + return; updateMobileHudLayout(); const rafId = window.requestAnimationFrame(updateMobileHudLayout); @@ -272,8 +295,8 @@ const App: React.FC = () => { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; - const addLog = (text: string, type: 'info'|'error'|'warning'|'success' = 'info') => { - setLogs(prev => [...prev.slice(-20), { id: Math.random().toString(), text, type }]); + const addLog = (text: string, type: 'info' | 'error' | 'warning' | 'success' = 'info') => { + setLogs((prev) => [...prev.slice(-20), { id: Math.random().toString(), text, type }]); }; // Narrative System (Fase 2) @@ -283,15 +306,18 @@ const App: React.FC = () => { isPlaying: gameState === GameState.PLAYING && !tutorialActive, currentScenario: currentScenario }); - + // Early Warning System (Fase 2) - const handlePreventEvent = useCallback((warningId: string) => { - addLog(`Evento prevenido: ${warningId}`, 'success'); - playSuccess(); - // Small bonus for preventing events - // This could be integrated with useGameLogic if needed - }, [playSuccess]); - + const handlePreventEvent = useCallback( + (warningId: string) => { + addLog(`Evento prevenido: ${warningId}`, 'success'); + playSuccess(); + // Small bonus for preventing events + // This could be integrated with useGameLogic if needed + }, + [playSuccess] + ); + const { activeWarnings } = useEarlyWarningSystem({ stats, systems, @@ -300,26 +326,30 @@ const App: React.FC = () => { }); // Achievement System (Fase 3) - const handleAchievementUnlocked = useCallback((achievementId: string, points: number) => { - const achievement = ACHIEVEMENTS.find(a => a.id === achievementId); - if (achievement && !careerData.unlockedAchievements.includes(achievementId)) { - const newCareer = { - ...careerData, - unlockedAchievements: [...careerData.unlockedAchievements, achievementId], - careerPoints: careerData.careerPoints + points - }; - saveCareer(newCareer); - addLog(`🎉 Logro desbloqueado: ${achievement.title} (+${points} pts)`, 'success'); - playSuccess(); - } - }, [careerData, saveCareer, playSuccess]); + const handleAchievementUnlocked = useCallback( + (achievementId: string, points: number) => { + const achievement = ACHIEVEMENTS.find((a) => a.id === achievementId); + if (achievement && !careerData.unlockedAchievements.includes(achievementId)) { + const newCareer = { + ...careerData, + unlockedAchievements: [...careerData.unlockedAchievements, achievementId], + careerPoints: careerData.careerPoints + points + }; + saveCareer(newCareer); + addLog(`🎉 Logro desbloqueado: ${achievement.title} (+${points} pts)`, 'success'); + playSuccess(); + } + }, + [careerData, saveCareer, playSuccess] + ); - const { sessionData, trackEventResolved, trackSpending } = useAchievementSystem({ + const { trackEventResolved, trackSpending } = useAchievementSystem({ stats, systems, activeEvents, // Keep session progress across pause; only stop tracking when leaving gameplay flow. - isPlaying: (gameState === GameState.PLAYING || gameState === GameState.PAUSED) && !tutorialActive, + isPlaying: + (gameState === GameState.PLAYING || gameState === GameState.PAUSED) && !tutorialActive, careerData, onAchievementUnlocked: handleAchievementUnlocked }); @@ -342,7 +372,7 @@ const App: React.FC = () => { clearTimeout(impactTimeoutRef.current); } impactTimeoutRef.current = window.setTimeout(() => { - setActiveImpactFx(current => (current && current.id === impactId ? null : current)); + setActiveImpactFx((current) => (current && current.id === impactId ? null : current)); impactTimeoutRef.current = null; }, impactStyle.durationMs); @@ -355,13 +385,15 @@ const App: React.FC = () => { clearTimeout(freezeTimeoutRef.current); } freezeTimeoutRef.current = window.setTimeout(() => { - setActiveFreezePulse(current => (current && current.id === impactId ? null : current)); + setActiveFreezePulse((current) => (current && current.id === impactId ? null : current)); freezeTimeoutRef.current = null; }, impactStyle.freezeMs); - const directionX = impactStyle.origin.xPercent < 35 ? 1 : impactStyle.origin.xPercent > 65 ? -1 : 0; - const directionY = impactStyle.origin.yPercent < 40 ? 1 : impactStyle.origin.yPercent > 65 ? -1 : 0; - const punchAmplitude = (success ? 2.4 : 4.2) + (severity * (success ? 0.9 : 1.35)); + const directionX = + impactStyle.origin.xPercent < 35 ? 1 : impactStyle.origin.xPercent > 65 ? -1 : 0; + const directionY = + impactStyle.origin.yPercent < 40 ? 1 : impactStyle.origin.yPercent > 65 ? -1 : 0; + const punchAmplitude = (success ? 2.4 : 4.2) + severity * (success ? 0.9 : 1.35); setActiveCameraPunch({ id: impactId, x: directionX * punchAmplitude, @@ -371,13 +403,16 @@ const App: React.FC = () => { if (punchTimeoutRef.current) { clearTimeout(punchTimeoutRef.current); } - punchTimeoutRef.current = window.setTimeout(() => { - setActiveCameraPunch(current => (current && current.id === impactId ? null : current)); - punchTimeoutRef.current = null; - }, success ? 160 : 210); + punchTimeoutRef.current = window.setTimeout( + () => { + setActiveCameraPunch((current) => (current && current.id === impactId ? null : current)); + punchTimeoutRef.current = null; + }, + success ? 160 : 210 + ); if (success) { - setExplosionTrigger(t => !t); + setExplosionTrigger((t) => !t); addLog(`${systemId}: ${eventTitle} RESUELTO`, 'success'); } else { addLog(`${systemId}: ${eventTitle} RESOLUCIÓN FALLIDA`, 'error'); @@ -386,38 +421,41 @@ const App: React.FC = () => { }, [setEventResolutionCallback, trackEventResolved, trackSpending, playEventResolutionSfx]); // Upgrade System (Fase 3) - const handleUpgradePurchased = useCallback((upgradeId: string, cost: number) => { - const upgrade = PERMANENT_UPGRADES.find(u => u.id === upgradeId); - const missingRequirements = (upgrade?.requires || []).filter( - requirementId => !careerData.unlockedUpgrades.includes(requirementId) - ); - - if (missingRequirements.length > 0) { - addLog('Esta mejora todavía requiere desbloqueos previos', 'warning'); - playError(); - return; - } + const handleUpgradePurchased = useCallback( + (upgradeId: string, cost: number) => { + const upgrade = PERMANENT_UPGRADES.find((u) => u.id === upgradeId); + const missingRequirements = (upgrade?.requires || []).filter( + (requirementId) => !careerData.unlockedUpgrades.includes(requirementId) + ); + + if (missingRequirements.length > 0) { + addLog('Esta mejora todavía requiere desbloqueos previos', 'warning'); + playError(); + return; + } - // Verificar que no esté ya desbloqueado y que tenga puntos suficientes - if (careerData.unlockedUpgrades.includes(upgradeId)) { - addLog('Esta mejora ya está desbloqueada', 'warning'); - return; // Ya está desbloqueado - } - if (careerData.careerPoints < cost) { - addLog('No tienes suficientes puntos de carrera', 'error'); - playError(); - return; // No tiene puntos suficientes - } + // Verificar que no esté ya desbloqueado y que tenga puntos suficientes + if (careerData.unlockedUpgrades.includes(upgradeId)) { + addLog('Esta mejora ya está desbloqueada', 'warning'); + return; // Ya está desbloqueado + } + if (careerData.careerPoints < cost) { + addLog('No tienes suficientes puntos de carrera', 'error'); + playError(); + return; // No tiene puntos suficientes + } - const newCareer = { - ...careerData, - unlockedUpgrades: [...careerData.unlockedUpgrades, upgradeId], - careerPoints: careerData.careerPoints - cost - }; - saveCareer(newCareer); - addLog(`✅ Mejora desbloqueada: ${upgrade?.name || upgradeId}`, 'success'); - playSuccess(); - }, [careerData, saveCareer, playSuccess, playError]); + const newCareer = { + ...careerData, + unlockedUpgrades: [...careerData.unlockedUpgrades, upgradeId], + careerPoints: careerData.careerPoints - cost + }; + saveCareer(newCareer); + addLog(`✅ Mejora desbloqueada: ${upgrade?.name || upgradeId}`, 'success'); + playSuccess(); + }, + [careerData, saveCareer, playSuccess, playError] + ); const { getAvailableUpgrades, purchaseUpgrade } = useUpgradeSystem({ careerData, @@ -435,58 +473,57 @@ const App: React.FC = () => { // Audio Loop Logic useEffect(() => { - if (gameState === GameState.PLAYING) { - startBackgroundLoop(); - } else { - stopBackgroundLoop(); - } - return () => { - stopBackgroundLoop(); - }; + if (gameState === GameState.PLAYING) { + startBackgroundLoop(); + } else { + stopBackgroundLoop(); + } + return () => { + stopBackgroundLoop(); + }; }, [gameState, startBackgroundLoop, stopBackgroundLoop]); // Audio Reactive Updates useEffect(() => { - if (gameState === GameState.PLAYING) { - updateBackgroundLoop( - systems[SystemType.SOUND].faderValue, - systems[SystemType.SOUND].status, - stats.stress, - systems[SystemType.STAGE].faderValue, - stats.publicInterest, - systems - ); - } + if (gameState === GameState.PLAYING) { + updateBackgroundLoop( + systems[SystemType.SOUND].faderValue, + systems[SystemType.SOUND].status, + stats.stress, + systems[SystemType.STAGE].faderValue, + stats.publicInterest, + systems + ); + } }, [gameState, systems, stats.stress, stats.publicInterest, updateBackgroundLoop]); - useEffect(() => { - const prevIds = prevEventIdsRef.current; - const newEvents = activeEvents.filter(event => !prevIds.has(event.id)); - - if (newEvents.length > 0) { - newEvents.forEach((newEvent) => { - const isEscalation = Boolean(newEvent.escalatedFrom); - addLog( - `${newEvent.systemId}: ${newEvent.title} ${isEscalation ? 'ESCALATED' : 'DETECTED'}`, - isEscalation || newEvent.severity === 3 ? 'error' : 'warning' - ); - - if (isEscalation) { - playEventEscalationSfx({ systemId: newEvent.systemId, severity: newEvent.severity }); - setScreenShake(true); - setTimeout(() => setScreenShake(false), 450); - } else if (newEvent.severity === 3) { - playAlarm(); - setScreenShake(true); - setTimeout(() => setScreenShake(false), 500); - } else { - playError(); - } - }); - } - - prevEventIdsRef.current = new Set(activeEvents.map(event => event.id)); + const prevIds = prevEventIdsRef.current; + const newEvents = activeEvents.filter((event) => !prevIds.has(event.id)); + + if (newEvents.length > 0) { + newEvents.forEach((newEvent) => { + const isEscalation = Boolean(newEvent.escalatedFrom); + addLog( + `${newEvent.systemId}: ${newEvent.title} ${isEscalation ? 'ESCALATED' : 'DETECTED'}`, + isEscalation || newEvent.severity === 3 ? 'error' : 'warning' + ); + + if (isEscalation) { + playEventEscalationSfx({ systemId: newEvent.systemId, severity: newEvent.severity }); + setScreenShake(true); + setTimeout(() => setScreenShake(false), 450); + } else if (newEvent.severity === 3) { + playAlarm(); + setScreenShake(true); + setTimeout(() => setScreenShake(false), 500); + } else { + playError(); + } + }); + } + + prevEventIdsRef.current = new Set(activeEvents.map((event) => event.id)); }, [activeEvents, playAlarm, playError, playEventEscalationSfx]); const criticalEventCount = useMemo( @@ -497,10 +534,7 @@ const App: React.FC = () => { () => activeEvents.reduce((acc, event) => acc + (event.severity === 2 ? 1 : 0), 0), [activeEvents] ); - const sortedActiveEvents = useMemo( - () => sortEventsByUrgency(activeEvents), - [activeEvents] - ); + const sortedActiveEvents = useMemo(() => sortEventsByUrgency(activeEvents), [activeEvents]); const mobilePrimaryEvent = sortedActiveEvents[0] || null; const mobileQueuedEvents = Math.max(0, sortedActiveEvents.length - 1); const mobileCenterHeight = useMemo(() => { @@ -526,7 +560,9 @@ const App: React.FC = () => { criticalEvents: criticalEventCount, warningEvents: warningEventCount, stress: stats.stress, - overlaysActive: Boolean(activeMission || activeNarrative || activeWarnings.length > 0 || clientMessage) + overlaysActive: Boolean( + activeMission || activeNarrative || activeWarnings.length > 0 || clientMessage + ) }); }, [ activeMission, @@ -540,31 +576,30 @@ const App: React.FC = () => { warningEventCount ]); - const handleStartSession = (scenarioId: string, crewId: string, gameMode: GameMode) => { - playClick(); - const resolvedScenarioId = initializeSession(scenarioId, crewId, gameMode); // Goes to Shop - setScenarioAudioProfile(resolvedScenarioId); - setGameModeAudioPreset(gameMode); - playScenarioTransitionSfx(resolvedScenarioId, 'LOAD'); - if (resolvedScenarioId !== scenarioId) { - setSelectedScenarioId(resolvedScenarioId); - addLog(`Escenario bloqueado. Redirigido a ${resolvedScenarioId}.`, 'warning'); - } - addLog(`Loading Scenario Config: ${resolvedScenarioId} (${gameMode})...`, 'info'); + playClick(); + const resolvedScenarioId = initializeSession(scenarioId, crewId, gameMode); // Goes to Shop + setScenarioAudioProfile(resolvedScenarioId); + setGameModeAudioPreset(gameMode); + playScenarioTransitionSfx(resolvedScenarioId, 'LOAD'); + if (resolvedScenarioId !== scenarioId) { + setSelectedScenarioId(resolvedScenarioId); + addLog(`Escenario bloqueado. Redirigido a ${resolvedScenarioId}.`, 'warning'); + } + addLog(`Loading Scenario Config: ${resolvedScenarioId} (${gameMode})...`, 'info'); }; - + const handleShopFinish = () => { - playStartGame(currentScenario.id); - startGame(); // Goes to Playing - setLogs([]); - addLog('SYSTEM INITIALIZED. SHOW STARTED.', 'success'); + playStartGame(currentScenario.id); + startGame(); // Goes to Playing + setLogs([]); + addLog('SYSTEM INITIALIZED. SHOW STARTED.', 'success'); }; const handleResolveEvent = (id: string, option: GameEventOption) => { - playClick(); - resolveEvent(id, option); - addLog(`User Action: ${option.label}`, 'info'); + playClick(); + resolveEvent(id, option); + addLog(`User Action: ${option.label}`, 'info'); }; const handleSystemSelect = (sys: SystemType) => { @@ -586,36 +621,51 @@ const App: React.FC = () => { restoreDefaultSettings(); addLog('Ajustes restablecidos a valores por defecto', 'warning'); }, [addLog, restoreDefaultSettings]); - + const getGameOverReason = () => { - if (gameState === GameState.VICTORY) return { - title: "MISIÓN CUMPLIDA", - desc: "Evento finalizado con éxito.", - icon: Trophy, color: "text-emerald-400" + if (gameState === GameState.VICTORY) + return { + title: 'MISIÓN CUMPLIDA', + desc: 'Evento finalizado con éxito.', + icon: Trophy, + color: 'text-emerald-400' }; - if (stats.stress >= 100) return { - title: "COLAPSO NERVIOSO", - desc: "El operador no pudo manejar la presión.", - icon: ZapOff, color: "text-red-500" + if (stats.stress >= 100) + return { + title: 'COLAPSO NERVIOSO', + desc: 'El operador no pudo manejar la presión.', + icon: ZapOff, + color: 'text-red-500' }; - if (stats.publicInterest <= 0) return { - title: "PÚBLICO HOSTIL", - desc: "Disturbios generalizados.", - icon: Users, color: "text-red-500" + if (stats.publicInterest <= 0) + return { + title: 'PÚBLICO HOSTIL', + desc: 'Disturbios generalizados.', + icon: Users, + color: 'text-red-500' }; - if (stats.clientSatisfaction <= 0) return { - title: "DESPEDIDO", - desc: "El cliente canceló el contrato.", - icon: Frown, color: "text-red-500" + if (stats.clientSatisfaction <= 0) + return { + title: 'DESPEDIDO', + desc: 'El cliente canceló el contrato.', + icon: Frown, + color: 'text-red-500' }; - if (stats.budget < WIN_CONDITIONS.minBudget) return { - title: "QUIEBRA", - desc: "Fondos insuficientes.", - icon: DollarSign, color: "text-red-500" + if (stats.budget < WIN_CONDITIONS.minBudget) + return { + title: 'QUIEBRA', + desc: 'Fondos insuficientes.', + icon: DollarSign, + color: 'text-red-500' }; - return { title: "GAME OVER", desc: "Sistema terminado.", icon: AlertOctagon, color: "text-red-500" }; + return { + title: 'GAME OVER', + desc: 'Sistema terminado.', + icon: AlertOctagon, + color: 'text-red-500' + }; }; - + // DAMAGE EFFECTS const videoCritical = systems[SystemType.VIDEO].status === 'CRITICAL'; const stageSmoke = systems[SystemType.STAGE].faderValue > 80; @@ -649,7 +699,10 @@ const App: React.FC = () => { const showMobileCombo = mobileOverlayVisibility.showCombo; const showMobileSocialFeed = mobileOverlayVisibility.showSocialFeed; const runtimeVisualProfile = useMemo(() => { - const nav = typeof navigator !== 'undefined' ? (navigator as Navigator & { deviceMemory?: number }) : null; + const nav = + typeof navigator !== 'undefined' + ? (navigator as Navigator & { deviceMemory?: number }) + : null; const deviceMemoryGb = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8; const hardwareConcurrency = nav?.hardwareConcurrency || 8; @@ -703,7 +756,8 @@ const App: React.FC = () => { // Fase 4: Paleta de colores dinámica const dynamicColors = useDynamicColors(stats, systems); - const artDirectionScenarioId = gameState === GameState.MENU ? selectedScenarioId : currentScenario.id; + const artDirectionScenarioId = + gameState === GameState.MENU ? selectedScenarioId : currentScenario.id; const artDirectionMode = gameState === GameState.MENU ? selectedGameMode : currentGameMode; const artDirectionProfile = useMemo( () => getScenarioArtDirectionProfile(artDirectionScenarioId), @@ -720,38 +774,36 @@ const App: React.FC = () => { }), [artDirectionMode, artDirectionScenarioId, gameState, reducedMotion, threatLevel] ); - const shellStyle = useMemo( - () => { - const sceneImage = getSceneBackgroundAsset(gameState, isMobileLayout); - const sceneImageOpacity = - gameState === GameState.MENU - ? 0.72 - : gameState === GameState.PLAYING || gameState === GameState.PAUSED - ? 0.44 - : 0.36; - - return { - ...artDirectionCssVariables, - ...getFxTextureCssVariables(), - ['--aaa-scene-image' as string]: `url('${sceneImage}')`, - ['--aaa-scene-image-opacity' as string]: `${sceneImageOpacity}`, - background: gameState === GameState.PLAYING + const shellStyle = useMemo(() => { + const sceneImage = getSceneBackgroundAsset(gameState, isMobileLayout); + const sceneImageOpacity = + gameState === GameState.MENU + ? 0.72 + : gameState === GameState.PLAYING || gameState === GameState.PAUSED + ? 0.44 + : 0.36; + + return { + ...artDirectionCssVariables, + ...getFxTextureCssVariables(), + ['--aaa-scene-image' as string]: `url('${sceneImage}')`, + ['--aaa-scene-image-opacity' as string]: `${sceneImageOpacity}`, + background: + gameState === GameState.PLAYING ? `linear-gradient(160deg, ${artDirectionProfile.shell.from} 0%, ${dynamicColors.bgColor} 52%, ${artDirectionProfile.shell.to} 100%)` : `linear-gradient(160deg, ${artDirectionProfile.shell.from}, ${artDirectionProfile.shell.mid} 56%, ${artDirectionProfile.shell.to})`, - filter: highContrastUi ? 'contrast(1.12) saturate(1.08)' : undefined - } as React.CSSProperties; - }, - [ - artDirectionCssVariables, - artDirectionProfile.shell.from, - artDirectionProfile.shell.mid, - artDirectionProfile.shell.to, - dynamicColors.bgColor, - gameState, - highContrastUi, - isMobileLayout - ] - ); + filter: highContrastUi ? 'contrast(1.12) saturate(1.08)' : undefined + } as React.CSSProperties; + }, [ + artDirectionCssVariables, + artDirectionProfile.shell.from, + artDirectionProfile.shell.mid, + artDirectionProfile.shell.to, + dynamicColors.bgColor, + gameState, + highContrastUi, + isMobileLayout + ]); const transitionTintClass: Record = { CYAN: 'from-cyan-400/35 via-sky-400/20 to-transparent', AMBER: 'from-amber-400/35 via-orange-400/20 to-transparent', @@ -766,20 +818,21 @@ const App: React.FC = () => { ? 'from-amber-400/75 via-orange-300/70 to-amber-400/75' : 'from-cyan-400/72 via-sky-300/65 to-cyan-400/72'; const impactOverlayToneClass = activeImpactFx?.success ? 'aaa-impact-success' : 'aaa-impact-fail'; - const freezeOverlayToneClass = activeFreezePulse?.tint === 'FAIL' ? 'aaa-freeze-fail' : 'aaa-freeze-success'; + const freezeOverlayToneClass = + activeFreezePulse?.tint === 'FAIL' ? 'aaa-freeze-fail' : 'aaa-freeze-success'; const menuToolbarClasses = useMemo( () => getMenuToolbarClasses(isMobileLayout, !isMobileLayout && isCompactViewport), [isCompactViewport, isMobileLayout] ); return ( -
{/* --- ATMOSPHERE LAYERS --- */}
@@ -789,35 +842,39 @@ const App: React.FC = () => {
{/* --- DAMAGE OVERLAYS --- */} - + {/* Video Failure Glitch */} {videoCritical && ( -
-
-
+
+
+
)} {/* Smoke Fog */} {stageSmoke && ( -
+
)} {(gameState === GameState.PLAYING || gameState === GameState.PAUSED) && (
)} @@ -825,9 +882,15 @@ const App: React.FC = () => { {activeTransition && (
-
+
{activeTransition.label} @@ -839,15 +902,17 @@ const App: React.FC = () => { {activeImpactFx && (
@@ -857,125 +922,145 @@ const App: React.FC = () => { {activeCameraPunch && (
)} {activeFreezePulse && (
)} {/* --- GAME UI --- */} {gameState === GameState.MENU && ( - <> - {}} - onRestart={() => {}} - onQuit={() => {}} - customStart={handleStartSession} - selectedScenarioId={selectedScenarioId} + <> + {}} + onRestart={() => {}} + onQuit={() => {}} + customStart={handleStartSession} + selectedScenarioId={selectedScenarioId} onSelectScenario={setSelectedScenarioId} selectedGameMode={selectedGameMode} onSelectGameMode={setSelectedGameMode} - scenarios={SCENARIOS} + scenarios={SCENARIOS} careerData={careerData} menuPanelOffsetClass={menuToolbarClasses.menuPanelOffset} isCompactLayout={isCompactViewport} isMobileLayout={isMobileLayout} reducedMotion={reducedMotion} - /> - {/* Fase 3: Botones de Logros y Mejoras */} -
- - - - -
- {showAchievements && ( - setShowAchievements(false)} - /> - )} - {showUpgrades && ( - { - const result = purchaseUpgrade(upgrade); - if (result.status === 'MISSING_REQUIREMENTS') { - const requiredNames = result.missingRequirements - .map(requirementId => PERMANENT_UPGRADES.find(item => item.id === requirementId)?.name || requirementId) - .join(', '); - addLog(`Mejora bloqueada. Requiere: ${requiredNames}`, 'warning'); - playError(); - } else if (result.status === 'INSUFFICIENT_POINTS') { - addLog(`No tienes suficientes puntos para ${upgrade.name}`, 'error'); - playError(); - } else if (result.status === 'ALREADY_UNLOCKED') { - addLog(`La mejora ${upgrade.name} ya está desbloqueada`, 'warning'); - } - }} - onClose={() => setShowUpgrades(false)} - /> - )} - + /> + {/* Fase 3: Botones de Logros y Mejoras */} +
+ + + + +
+ {showAchievements && ( + setShowAchievements(false)} + /> + )} + {showUpgrades && ( + { + const result = purchaseUpgrade(upgrade); + if (result.status === 'MISSING_REQUIREMENTS') { + const requiredNames = result.missingRequirements + .map( + (requirementId) => + PERMANENT_UPGRADES.find((item) => item.id === requirementId)?.name || + requirementId + ) + .join(', '); + addLog(`Mejora bloqueada. Requiere: ${requiredNames}`, 'warning'); + playError(); + } else if (result.status === 'INSUFFICIENT_POINTS') { + addLog(`No tienes suficientes puntos para ${upgrade.name}`, 'error'); + playError(); + } else if (result.status === 'ALREADY_UNLOCKED') { + addLog(`La mejora ${upgrade.name} ya está desbloqueada`, 'warning'); + } + }} + onClose={() => setShowUpgrades(false)} + /> + )} + )} {gameState === GameState.SHOP && ( - { playClick(); buyItem(item); }} - onStart={handleShopFinish} - /> + { + playClick(); + buyItem(item); + }} + onStart={handleShopFinish} + /> )} - + {gameState === GameState.PAUSED && ( { playClick(); togglePause(); }} + onResume={() => { + playClick(); + togglePause(); + }} onRestart={() => { playClick(); - const restartScenario = pendingStartData?.scenarioId || currentScenario.id || selectedScenarioId; + const restartScenario = + pendingStartData?.scenarioId || currentScenario.id || selectedScenarioId; const restartCrew = pendingStartData?.crewId || 'VETERAN'; const restartMode = pendingStartData?.gameMode || currentGameMode; handleStartSession(restartScenario, restartCrew, restartMode); }} - onQuit={() => { playClick(); quitGame(); }} + onQuit={() => { + playClick(); + quitGame(); + }} isCompactLayout={isCompactViewport} isMobileLayout={isMobileLayout} reducedMotion={reducedMotion} @@ -984,230 +1069,279 @@ const App: React.FC = () => { {(gameState === GameState.PLAYING || gameState === GameState.PAUSED) && ( <> - + + {/* Alarm Overlay */} +
80 ? 'opacity-100 animate-alarm' : 'opacity-0'}`} + style={{ boxShadow: 'inset 0 0 100px rgba(220, 38, 38, 0.4)' }} + >
+ + {!isMobileLayout && ( + <> + {/* MISSION PANEL */} + {activeMission && } + + {/* COMBO INDICATOR */} + + + {/* NARRATIVE POPUP (Fase 2) */} + {activeNarrative && ( + + )} + + {/* EARLY WARNING PANEL (Fase 2) */} + + + )} + + {isMobileLayout && ( +
+ {showMobileMission && activeMission && ( + + )} + {showMobilePrimaryEvent && mobilePrimaryEvent && ( +
+ {mobileQueuedEvents > 0 && ( +
+ + +{mobileQueuedEvents} EN COLA + +
+ )} + +
+ )} + {showMobileWarnings && ( + + )} + {showMobileNarrative && activeNarrative && ( + + )} + {showMobileClientPopup && ( + + )} + {showMobileCombo && } + {showMobileSocialFeed && } +
+ )} + + {/* TUTORIAL OVERLAY */} + {tutorialActive && TUTORIAL_STEPS[tutorialStepIndex] && ( + { + playClick(); + if (tutorialStepIndex === TUTORIAL_STEPS.length - 1) { + finishTutorial(); + } else { + advanceTutorial(); + } + }} /> - - {/* Alarm Overlay */} -
80 ? 'opacity-100 animate-alarm' : 'opacity-0'}`} - style={{ boxShadow: 'inset 0 0 100px rgba(220, 38, 38, 0.4)' }} - >
- - {!isMobileLayout && ( - <> - {/* MISSION PANEL */} - {activeMission && } - - {/* COMBO INDICATOR */} - - - {/* NARRATIVE POPUP (Fase 2) */} - {activeNarrative && ( - + )} + + {!isMobileLayout && ( + + )} + {!isMobileLayout && } + + {activeMinigame && ( + + )} + + {/* HEADER */} +
+
+ + {/* Clock LCD */} +
+
+ TIME + {stats.timeRemaining < 30 && ( + )} - - {/* EARLY WARNING PANEL (Fase 2) */} - - - )} - - {isMobileLayout && ( +
- {showMobileMission && activeMission && } - {showMobilePrimaryEvent && mobilePrimaryEvent && ( -
- {mobileQueuedEvents > 0 && ( -
- - +{mobileQueuedEvents} EN COLA - -
- )} - -
- )} - {showMobileWarnings && ( - - )} - {showMobileNarrative && activeNarrative && ( - - )} - {showMobileClientPopup && ( - - )} - {showMobileCombo && } - {showMobileSocialFeed && } + {formatTime(stats.timeRemaining)}
- )} - - {/* TUTORIAL OVERLAY */} - {tutorialActive && TUTORIAL_STEPS[tutorialStepIndex] && ( - { - playClick(); - if (tutorialStepIndex === TUTORIAL_STEPS.length - 1) { - finishTutorial(); - } else { - advanceTutorial(); - } - }} - /> - )} - - {!isMobileLayout && } - {!isMobileLayout && } - - {activeMinigame && ( - - )} - - {/* HEADER */} -
-
- - {/* Clock LCD */} -
-
- TIME - {stats.timeRemaining < 30 && } -
-
- {formatTime(stats.timeRemaining)} -
+
+ +
+ + + 80 ? dynamicColors.danger : dynamicColors.warning} + /> + +
+
+ Fondos
- -
- - - 80 ? dynamicColors.danger : dynamicColors.warning} /> - -
-
- Fondos -
-
- - {stats.budget.toLocaleString()} -
-
+
+ + {stats.budget.toLocaleString()}
+
+
- - - + + + +
+ +
+
+ {/* CENTER: 2D Visualizer */} +
+
- AUD {audioSpatialMode.slice(0, 4)} - - -
- -
-
- - {/* CENTER: 2D Visualizer */} -
-
- -
- -
- -
-
- - {/* RIGHT: Alerts */} - {!isMobileLayout && ( - - )} +
-
- +
+
+ + + {/* RIGHT: Alerts */} + {!isMobileLayout && ( + + )}
+ +
+ +
+
)} {showSettings && ( { playClick(); setShowSettings(false); }} + onClose={() => { + playClick(); + setShowSettings(false); + }} visualQualityMode={visualQualityMode} onVisualQualityChange={(mode) => { setVisualQualityMode(mode); @@ -1231,39 +1365,50 @@ const App: React.FC = () => { {/* GAME OVER OVERLAY */} {(gameState === GameState.GAME_OVER || gameState === GameState.VICTORY) && ( -
- {(() => { - const reason = getGameOverReason(); - const Icon = reason.icon; - return ( -
-
-
- -
-
-

- {reason.title} -

-

{reason.desc}

- - -
- ); - })()} -
+
+ {(() => { + const reason = getGameOverReason(); + const Icon = reason.icon; + return ( +
+
+
+ +
+
+

+ {reason.title} +

+

{reason.desc}

+ + +
+ ); + })()} +
)}
); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdda364 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 MateCode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +This MIT license covers the source code only. + +The visual and audio assets of Event Chaos are licensed separately and more +restrictively. See ASSETS-LICENSE.md. diff --git a/README.md b/README.md index 5baacf1..037f579 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,31 @@ A browser game about surviving a live production under pressure. You are in the technical booth. Cues stack up, clients change their minds mid-show, equipment fails at the worst possible moment, and every decision costs you something. Event Chaos turns running live events into a management sim — the fantasy is not power, it is holding a show together while it tries to fall apart. -Plays on desktop and mobile. +**[Play it →](https://event-chaos.netlify.app)** · Desktop and mobile · No install, no account + +--- + +## Run it locally + +Requires Node.js 20 — the version CI and the deploy both run. + +```bash +npm install +npm run dev +``` + +That is the whole setup. The game is offline-first: no API keys, no services, no backend. If an AI key happens to exist in the environment it is treated as optional metadata, never as a requirement. + +| Command | What it does | +| ----------------- | ------------------------------------- | +| `npm run dev` | Development server | +| `npm run build` | Production build into `dist/` | +| `npm run preview` | Serve that build locally | +| `npm run check` | Everything CI runs, in the same order | + +Run `npm run check` before opening a pull request and the pipeline should hold no surprises. + +--- ## Design @@ -14,71 +38,97 @@ The interface is treated as professional show equipment rather than a generic ga The full art direction is written down in [`docs/ART_BIBLE.md`](docs/ART_BIBLE.md) and enforced at runtime through a shared theme system instead of per-component styling. -## Systems +## Architecture -| Area | Where it lives | -| --- | --- | -| Art direction and theming | `utils/artDirection.ts` | -| UI primitives | `utils/uiSystem.ts`, `components/Button.tsx` | -| HUD and menu cinematics | `utils/uiCinematics.ts` | -| Mobile overlay safety rules | `utils/mobileUiPolicy.ts` | -| Runtime asset mapping | `utils/artAssets.ts` | -| Game rules — pacing, economy, missions, events | `hooks/gameLogic/` | -| Player preferences and viewport | `hooks/useUserSettings.ts`, `hooks/useViewportLayout.ts` | +The rules of the game are pure functions with no React in them. They live in `hooks/gameLogic/` and are re-exported through `useGameLogic`, which owns the stateful half — so pacing, economy and event logic can be tested directly, without rendering anything. -Gameplay is composed from focused panels — missions, achievements, shop, minigames, narrative popups, social feed, early warnings — rather than one monolithic scene. - -The rules of the game are pure functions with no React in them, kept in `hooks/gameLogic/` and re-exported through `useGameLogic`, which owns the stateful half. +| Area | Where it lives | +| ---------------------------------------------- | -------------------------------------------------------- | +| Game rules — pacing, economy, missions, events | `hooks/gameLogic/` | +| Session state and the game loop | `hooks/useGameLogic.ts` | +| Art direction and theming | `utils/artDirection.ts` | +| UI primitives | `utils/uiSystem.ts`, `components/Button.tsx` | +| HUD and menu cinematics | `utils/uiCinematics.ts` | +| Mobile overlay safety rules | `utils/mobileUiPolicy.ts` | +| Runtime asset mapping | `utils/artAssets.ts` | +| Player preferences and viewport | `hooks/useUserSettings.ts`, `hooks/useViewportLayout.ts` | +| Storage that never throws | `utils/safeStorage.ts` | -## Tech stack +Gameplay is composed from focused panels — missions, achievements, shop, minigames, narrative popups, social feed, early warnings — rather than one monolithic scene. -| Layer | Technology | -| --- | --- | -| UI | React + TypeScript | -| Build | Vite | +| Layer | Technology | +| ------- | -------------------------------- | +| UI | React + TypeScript, `strict` on | +| Build | Vite | | Styling | Tailwind, compiled at build time | -| Icons | Lucide | +| Icons | Lucide | +| Tests | Vitest, node and jsdom | + +--- -## Getting started +## Testing -Requires Node.js 20 — the version CI and the deploy both run. +Design rules that break silently — mobile overlay safety, UI cinematics, asset mappings — are covered by regression tests rather than left to review. So is accessibility. ```bash -npm install -npm run dev # development server -npm run build # production build -npm run preview # serve the production build +npm run test # full suite, about three seconds +npm run test:watch # same suite, watching ``` -The game runs fully offline-first and needs no external AI API key. If one is present in the environment it is treated as optional runtime metadata, never as a requirement. +Logic suites are plain `.test.ts` files on the fast `node` environment. Component suites are `.test.tsx` and opt into a DOM with a `// @vitest-environment jsdom` docblock on the first line. -Google Fonts is the only third-party origin the game touches at runtime. Everything else — styles, textures, portraits — is served from the same origin. +Assertions use Vitest's own matchers, deliberately not `jest-dom`, whose matcher types would need extra augmentation to keep `tsc --noEmit` clean. -## Tests +## Accessibility -Design rules that break silently — mobile overlay safety, UI cinematics, asset mappings — are covered by regression tests rather than left to review. So is accessibility: the faders are the core mechanic and are fully keyboard operable, and a test fails if that stops being true. +The faders are the core mechanic, so they are a real slider widget rather than a styled `div`: focusable, following the WAI-ARIA pattern, and announcing the zone rather than a bare number — staying inside the safe zone is the actual objective. -```bash -npm run test # full suite -npm run test:watch # same suite, watching -npm run test:playtest # playtest scenarios -npm run typecheck -npm run check # everything CI runs, in the same order -``` +| Key | Effect | +| ------------------------------------- | ---------------------- | +| | Move by 2 | +| PageUp PageDown | Move by 10 | +| Home End | Jump to either extreme | + +Overlays are labelled modal dialogs, the terminal and client messages are live regions, warnings are assertive, and decorative canvases stay out of the accessibility tree. `tests/fader-panel-a11y.test.tsx` and `tests/ui-accessibility-contract.test.tsx` fail if any of that stops being true. + +--- + +## Before you change things -Logic suites are plain `.test.ts` files on the fast `node` environment. Component suites are `.test.tsx` and opt into a DOM with a `// @vitest-environment jsdom` docblock on the first line. Assertions use Vitest's own matchers, deliberately not `jest-dom`, whose matcher types would need augmentation to keep `tsc --noEmit` clean. +Three things in this repo break quietly. They are worth knowing before your first pull request. -## Styling +**The Tailwind safelist is load-bearing.** Three components assemble class names at runtime with `String.replace`, which the content scanner cannot see. Those classes live in the `safelist` in `tailwind.config.js`, and the comment there names the files to keep in sync. Delete them and the build still passes — it just ships uncoloured progress bars and combo indicators. -Tailwind is compiled at build time through `tailwind.config.js` and `postcss.config.js` — never loaded from a CDN. +**Nothing may reach a CDN at runtime.** Styles, textures and portraits are all served from the same origin. Google Fonts is the single third-party origin the Content-Security-Policy allows; anything else is blocked in production, not merely discouraged. -Three components assemble class names at runtime with `String.replace`, which the content scanner cannot see. Those classes live in the config's `safelist`, and the comment there names the files to keep in sync. Removing them silently ships uncoloured progress bars and combo indicators. +**Assets are sized for how they are drawn, not for how they look in a folder.** Crew portraits render into 40×40 boxes. `npm run check:budget` fails the build if `dist/` outgrows its budget, because this project has shipped a 19 MB bundle before. + +## Quality gates + +Every push and pull request runs the same pipeline, pinned to Node 20 so CI and the deploy never disagree about the runtime. + +``` +typecheck → lint → format → test → build → bundle budget +``` + +`eslint.config.js` keeps four React Compiler rules at warning level, with a comment explaining why: they flag real patterns in the game loop — randomness read during render, refs touched during render, state set from effects — and fixing them means restructuring how the simulation ticks. The count should only ever go down. ## Shipping `netlify.toml` carries the build command, the SPA redirect, cache headers and the Content-Security-Policy. Hashed build output and versioned art are cached for a year; `index.html` never is. -`npm run check:budget` fails the build if `dist/` outgrows its size budget. The limits are loose on purpose — they exist to catch a 19 MB bundle, not to police kilobytes. +## License + +Code and art are licensed separately. + +| What | License | Short version | +| -------------------- | ------------------------------------ | -------------------------------------------------- | +| Source code | [MIT](LICENSE) | Use it, change it, ship it | +| Art and audio design | [CC BY-NC-ND 4.0](ASSETS-LICENSE.md) | Credit required, no commercial use, no derivatives | + +Fork it, read it, learn from it — that is what the MIT license is there for. The +artwork is the part that is not yours to reuse. Want to anyway? Ask at +[matecode.dev](https://matecode.dev). --- diff --git a/components/AchievementPanel.tsx b/components/AchievementPanel.tsx index 2f5d939..ebc2e92 100644 --- a/components/AchievementPanel.tsx +++ b/components/AchievementPanel.tsx @@ -8,7 +8,11 @@ interface AchievementPanelProps { onClose: () => void; } -export const AchievementPanel: React.FC = ({ achievements, unlockedIds, onClose }) => { +export const AchievementPanel: React.FC = ({ + achievements, + unlockedIds, + onClose +}) => { const categoryColors = { PERFORMANCE: 'text-blue-400', ECONOMY: 'text-green-400', @@ -26,7 +30,10 @@ export const AchievementPanel: React.FC = ({ achievements className="bg-slate-900 border-2 border-slate-600 rounded-xl shadow-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto" >
-

+

@@ -41,7 +48,7 @@ export const AchievementPanel: React.FC = ({ achievements
- {achievements.map(achievement => { + {achievements.map((achievement) => { const isUnlocked = unlockedIds.includes(achievement.id); return (
= ({ message, mood, onClose, mobile = false }) => { +export const ClientPopup: React.FC = ({ + message, + mood, + onClose, + mobile = false +}) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -21,25 +26,34 @@ export const ClientPopup: React.FC = ({ message, mood, onClose }, 8000); return () => clearTimeout(timer); } + return undefined; }, [message, onClose]); if (!message && !isVisible) return null; const getStyles = () => { switch (mood) { - case 'ANGRY': return 'border-red-500 bg-red-950/90 shadow-red-900/50'; - case 'PANIC': return 'border-orange-500 bg-orange-950/90 shadow-orange-900/50'; - case 'HAPPY': return 'border-emerald-500 bg-emerald-950/90 shadow-emerald-900/50'; - default: return 'border-slate-500 bg-slate-900/90 shadow-slate-900/50'; + case 'ANGRY': + return 'border-red-500 bg-red-950/90 shadow-red-900/50'; + case 'PANIC': + return 'border-orange-500 bg-orange-950/90 shadow-orange-900/50'; + case 'HAPPY': + return 'border-emerald-500 bg-emerald-950/90 shadow-emerald-900/50'; + default: + return 'border-slate-500 bg-slate-900/90 shadow-slate-900/50'; } }; const getIconColor = () => { switch (mood) { - case 'ANGRY': return 'text-red-500 animate-pulse'; - case 'PANIC': return 'text-orange-500 animate-bounce'; - case 'HAPPY': return 'text-emerald-400'; - default: return 'text-slate-400'; + case 'ANGRY': + return 'text-red-500 animate-pulse'; + case 'PANIC': + return 'text-orange-500 animate-bounce'; + case 'HAPPY': + return 'text-emerald-400'; + default: + return 'text-slate-400'; } }; @@ -58,45 +72,53 @@ export const ClientPopup: React.FC = ({ message, mood, onClose aria-label="Mensaje del cliente" className={`border-l-4 p-4 rounded-r-lg shadow-2xl backdrop-blur-md relative overflow-hidden ${getStyles()}`} > - {/* Scanlines overlay specific to popup */} - +
- {/* Client Avatar Placeholder */} - + {/* Client Avatar Placeholder */} + + +
+
+ + {mood === 'ANGRY' + ? 'CLIENTE (FURIOSO)' + : mood === 'HAPPY' + ? 'CLIENTE (VIP)' + : 'INCOMING MSG'} + + +
-
-
- - {mood === 'ANGRY' ? 'CLIENTE (FURIOSO)' : mood === 'HAPPY' ? 'CLIENTE (VIP)' : 'INCOMING MSG'} - - -
- -
- "{message}" -
-
+
+ "{message}" +
+
{/* Decoration */}
- +
diff --git a/components/ComboIndicator.tsx b/components/ComboIndicator.tsx index 423bf1c..e69f83f 100644 --- a/components/ComboIndicator.tsx +++ b/components/ComboIndicator.tsx @@ -9,7 +9,7 @@ interface ComboIndicatorProps { export const ComboIndicator: React.FC = ({ comboState, mobile = false }) => { const { streakSeconds, multiplier, perfectRhythm } = comboState; - + if (multiplier <= 1.0 && !perfectRhythm) return null; const getMultiplierColor = () => { @@ -27,8 +27,12 @@ export const ComboIndicator: React.FC = ({ comboState, mobi }; return ( -
-
+
+
{perfectRhythm ? ( <> @@ -37,16 +41,16 @@ export const ComboIndicator: React.FC = ({ comboState, mobi
RITMO PERFECTO
-
- Sistemas sincronizados -
+
Sistemas sincronizados
) : ( <>
-
+
COMBO x{multiplier.toFixed(1)}
@@ -57,14 +61,14 @@ export const ComboIndicator: React.FC = ({ comboState, mobi )}
- + {/* Progress bar for next milestone */} {!perfectRhythm && (
-
diff --git a/components/EarlyWarningPanel.tsx b/components/EarlyWarningPanel.tsx index 89595e6..b4333c7 100644 --- a/components/EarlyWarningPanel.tsx +++ b/components/EarlyWarningPanel.tsx @@ -14,13 +14,17 @@ export const EarlyWarningPanel: React.FC = ({ maxItems }) => { if (warnings.length === 0) return null; - const visibleWarnings = typeof maxItems === 'number' ? warnings.slice(0, Math.max(1, maxItems)) : warnings; + const visibleWarnings = + typeof maxItems === 'number' ? warnings.slice(0, Math.max(1, maxItems)) : warnings; const getSeverityColor = (severity: 'LOW' | 'MEDIUM' | 'HIGH') => { switch (severity) { - case 'HIGH': return 'border-red-500 bg-red-950/80'; - case 'MEDIUM': return 'border-orange-500 bg-orange-950/80'; - case 'LOW': return 'border-yellow-500 bg-yellow-950/80'; + case 'HIGH': + return 'border-red-500 bg-red-950/80'; + case 'MEDIUM': + return 'border-orange-500 bg-orange-950/80'; + case 'LOW': + return 'border-yellow-500 bg-yellow-950/80'; } }; @@ -31,7 +35,7 @@ export const EarlyWarningPanel: React.FC = ({ aria-label="Advertencias tempranas" className={`${mobile ? 'relative w-full shrink-0 space-y-2 pointer-events-auto' : 'absolute top-20 left-2 right-2 md:left-8 md:right-auto z-[100] md:w-80 space-y-2 max-h-[calc(100vh-120px)] overflow-y-auto'}`} > - {visibleWarnings.map(warning => ( + {visibleWarnings.map((warning) => (
= ({
-