Files
game-timer/app.js
2025-03-28 15:31:28 +01:00

446 lines
16 KiB
JavaScript

// app.js - Main Application Orchestrator
import * as config from './config.js';
import * as state from './state.js';
import * as ui from './ui.js';
import * as timer from './timer.js';
import camera from './camera.js'; // Default export
import audioManager from './audio.js';
import * as pushFlic from './pushFlicIntegration.js';
// --- Core Game Actions ---
function startGame() {
if (state.getPlayers().length < 2) {
alert('You need at least 2 players to start.');
return;
}
if (state.getGameState() === config.GAME_STATES.SETUP || state.getGameState() === config.GAME_STATES.PAUSED) {
state.setGameState(config.GAME_STATES.RUNNING);
audioManager.play('gameStart');
timer.startTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is applied
}
}
function pauseGame() {
if (state.getGameState() === config.GAME_STATES.RUNNING) {
state.setGameState(config.GAME_STATES.PAUSED);
audioManager.play('gamePause');
timer.stopTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is removed
}
}
function resumeGame() {
if (state.getGameState() === config.GAME_STATES.PAUSED) {
// Check if there's actually a player with time left
if (state.findNextPlayerWithTime() === -1) {
console.log("Cannot resume, no players have time left.");
// Optionally set state to OVER here
handleGameOver();
return;
}
state.setGameState(config.GAME_STATES.RUNNING);
audioManager.play('gameResume');
timer.startTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is applied
}
}
function togglePauseResume() {
const currentGameState = state.getGameState();
if (currentGameState === config.GAME_STATES.RUNNING) {
pauseGame();
} else if (currentGameState === config.GAME_STATES.PAUSED) {
resumeGame();
} else if (currentGameState === config.GAME_STATES.SETUP) {
startGame();
} else if (currentGameState === config.GAME_STATES.OVER) {
resetGame(); // Or just go back to setup? Let's reset.
startGame();
}
}
function switchToPlayer(index) {
if (index >= 0 && index < state.getPlayers().length) {
const previousIndex = state.getCurrentPlayerIndex();
if(index !== previousIndex) {
state.setCurrentPlayerIndex(index);
audioManager.play('playerSwitch');
ui.renderPlayers(); // Update UI immediately
// If the game is running, restart the timer for the new player
// The timer interval callback will handle the decrementing
if (state.getGameState() === config.GAME_STATES.RUNNING) {
timer.startTimer(); // This clears the old interval and starts anew
}
}
}
}
function nextPlayer() {
const currentGameState = state.getGameState();
let newIndex = -1;
if (currentGameState === config.GAME_STATES.RUNNING) {
newIndex = state.findNextPlayerWithTimeCircular(1); // Find next with time
} else {
// Allow cycling through all players if not running
const playerCount = state.getPlayers().length;
if(playerCount > 0) {
newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount;
}
}
if (newIndex !== -1) {
switchToPlayer(newIndex);
} else if (currentGameState === config.GAME_STATES.RUNNING) {
console.log("NextPlayer: No other player has time remaining.");
// Optionally handle game over immediately? Timer logic should catch this too.
}
}
function previousPlayer() {
const currentGameState = state.getGameState();
let newIndex = -1;
if (currentGameState === config.GAME_STATES.RUNNING) {
newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time
} else {
// Allow cycling through all players if not running
const playerCount = state.getPlayers().length;
if (playerCount > 0) {
newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount;
}
}
if (newIndex !== -1) {
switchToPlayer(newIndex);
} else if (currentGameState === config.GAME_STATES.RUNNING) {
console.log("PreviousPlayer: No other player has time remaining.");
}
}
function handleGameOver() {
state.setGameState(config.GAME_STATES.OVER);
audioManager.play('gameOver');
timer.stopTimer(); // Ensure timer is stopped
ui.updateGameButton();
ui.renderPlayers(); // Update to show final state
}
function resetGame() {
timer.stopTimer(); // Stop timer if running/paused
state.resetPlayersTime();
state.setGameState(config.GAME_STATES.SETUP);
state.setCurrentPlayerIndex(0); // Go back to first player
audioManager.play('buttonClick'); // Or a specific reset sound?
ui.updateGameButton();
ui.renderPlayers();
}
function fullResetApp() {
timer.stopTimer();
state.resetToDefaults();
audioManager.play('gameOver'); // Use game over sound for full reset
ui.hideResetModal();
ui.updateGameButton();
ui.renderPlayers();
}
// --- Event Handlers ---
function handleGameButtonClick() {
audioManager.play('buttonClick');
togglePauseResume();
}
function handleSetupButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before editing players.');
return;
}
const currentPlayer = state.getCurrentPlayer();
if (!currentPlayer) {
console.warn("Edit clicked but no current player?");
return; // Or show Add Player modal?
}
camera.stopStream(); // Ensure camera is off before opening modal
ui.showPlayerModal(false, currentPlayer);
}
function handleAddPlayerButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before adding players.');
return;
}
camera.stopStream(); // Ensure camera is off before opening modal
ui.showPlayerModal(true);
}
function handleResetButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before resetting.');
return;
}
ui.showResetModal();
}
function handlePlayerFormSubmit(event) {
event.preventDefault();
audioManager.play('buttonClick');
const name = ui.elements.playerNameInput.value.trim();
const timeInMinutes = parseInt(ui.elements.playerTimeInput.value, 10);
let remainingTimeSeconds = 0; // Default
const isNewPlayer = ui.elements.modalTitle.textContent === 'Add New Player';
const currentGameState = state.getGameState();
if (!name || isNaN(timeInMinutes) || timeInMinutes <= 0) {
alert('Please enter a valid name and positive time.');
return;
}
// Get remaining time ONLY if editing and game is paused/over
if (!isNewPlayer && (currentGameState === config.GAME_STATES.PAUSED || currentGameState === config.GAME_STATES.OVER)) {
const remainingTimeString = ui.elements.playerRemainingTimeInput.value;
const parsedSeconds = ui.parseTimeString(remainingTimeString);
if (parsedSeconds === null) { // Check if parsing failed
alert('Please enter remaining time in MM:SS format (e.g., 05:30).');
return;
}
remainingTimeSeconds = parsedSeconds;
// Validate remaining time against total time? Optional.
if (remainingTimeSeconds > timeInMinutes * 60) {
alert('Remaining time cannot be greater than the total time.');
return;
}
} else {
// For new players or when editing in setup, remaining time matches total time
remainingTimeSeconds = timeInMinutes * 60;
}
let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null;
const imageFile = ui.elements.playerImageInput.files[0];
const saveAction = (finalImageData) => {
if (isNewPlayer) {
state.addPlayer(name, timeInMinutes, finalImageData);
audioManager.play('playerAdded');
} else {
const playerIndex = state.getCurrentPlayerIndex();
// Use 'undefined' for image if no new image is provided, so state.updatePlayer keeps the old one
const imageArg = finalImageData !== null ? finalImageData : (imageFile || ui.elements.playerImageInput.dataset.capturedImage ? finalImageData : undefined);
state.updatePlayer(playerIndex, name, timeInMinutes, remainingTimeSeconds, imageArg);
audioManager.play('playerEdited');
}
ui.hidePlayerModal();
ui.renderPlayers();
ui.updateGameButton(); // Update in case player count changed for setup state
camera.stopStream(); // Ensure camera is stopped
};
if (!imageDataUrl && imageFile) {
// Handle file upload: Read file as Data URL
const reader = new FileReader();
reader.onload = (e) => saveAction(e.target.result);
reader.onerror = (e) => {
console.error("Error reading image file:", e);
alert("Error processing image file.");
};
reader.readAsDataURL(imageFile);
} else {
// Handle captured image or no image change
const currentImage = isNewPlayer ? null : state.getCurrentPlayer()?.image;
// If imageDataUrl has content (from camera), use it.
// If not, and no file was selected, keep the current image (by passing undefined to updatePlayer later).
// If it's a new player and no image, pass null.
saveAction(imageDataUrl ?? (isNewPlayer ? null : currentImage));
}
}
function handlePlayerModalCancel() {
audioManager.play('buttonClick');
ui.hidePlayerModal();
camera.stopStream(); // Make sure camera turns off
}
function handleDeletePlayer() {
audioManager.play('buttonClick');
const success = state.deletePlayer(state.getCurrentPlayerIndex());
if (success) {
audioManager.play('playerDeleted');
ui.hidePlayerModal();
ui.renderPlayers();
ui.updateGameButton(); // Update in case player count dropped below 2
} else {
alert('Cannot delete player. Minimum of 2 players required.');
}
camera.stopStream();
}
function handleResetConfirm() {
audioManager.play('buttonClick');
fullResetApp();
}
function handleResetCancel() {
audioManager.play('buttonClick');
ui.hideResetModal();
}
function handleCameraButtonClick(event) {
event.preventDefault(); // Prevent form submission if inside form
audioManager.play('buttonClick');
camera.open(); // Open the camera interface
}
// --- Timer Callbacks ---
function handleTimerTick() {
// Timer module already updated the state, just need to redraw UI
ui.renderPlayers();
}
function handlePlayerSwitchOnTimer(newPlayerIndex) {
// Timer detected current player ran out, found next player
console.log(`Timer switching to player index: ${newPlayerIndex}`);
switchToPlayer(newPlayerIndex);
// Sound is handled in switchToPlayer
}
// --- Camera Callback ---
function handleCameraCapture(imageDataUrl) {
console.log("Image captured");
ui.updateImagePreviewFromDataUrl(imageDataUrl);
// Camera module already closed the camera UI
}
// --- Service Worker and PWA Setup ---
function setupServiceWorker() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('ServiceWorker registered successfully.');
// Listen for messages FROM the Service Worker (e.g., Flic actions)
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);
// Initialize Flic integration (which will try to subscribe)
initFlic();
})
.catch(error => {
console.error('ServiceWorker registration failed:', error);
});
});
// Listen for SW controller changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker controller changed, potentially updated.');
// window.location.reload(); // Consider prompting user to reload
});
} else {
console.warn('ServiceWorker not supported.');
}
}
function handleServiceWorkerMessage(event) {
console.log('[App] Message received from Service Worker:', event.data);
if (event.data?.type === 'flic-action') {
const { action, button, timestamp, batteryLevel } = event.data;
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
}
}
// --- Flic Integration Setup ---
function initFlic() {
// Define what happens for each Flic button action
const flicActionHandlers = {
[config.FLIC_ACTIONS.SINGLE_CLICK]: nextPlayer,
[config.FLIC_ACTIONS.DOUBLE_CLICK]: previousPlayer, // Map double-click to previous player
[config.FLIC_ACTIONS.HOLD]: togglePauseResume,
};
pushFlic.initPushFlic(flicActionHandlers);
}
// --- Initialization ---
function initialize() {
console.log("Initializing Game Timer App...");
// 1. Load saved state or defaults
state.loadData();
// 2. Initialize UI (pass carousel swipe handler)
ui.initUI({
onCarouselSwipe: (direction) => {
if (direction > 0) nextPlayer(); else previousPlayer();
}
});
// 3. Initialize Timer (pass callbacks for UI updates/state changes)
timer.initTimer({
onTimerTick: handleTimerTick,
onPlayerSwitch: handlePlayerSwitchOnTimer,
onGameOver: handleGameOver
});
// 4. Initialize Camera (pass elements and capture callback)
camera.init(
{ // Pass relevant DOM elements
cameraContainer: ui.elements.cameraContainer,
cameraView: ui.elements.cameraView,
cameraCanvas: ui.elements.cameraCanvas,
cameraCaptureButton: ui.elements.cameraCaptureButton,
cameraCancelButton: ui.elements.cameraCancelButton
},
{ // Pass options/callbacks
onCapture: handleCameraCapture
}
);
// 5. Set up UI Event Listeners that trigger actions
ui.elements.gameButton.addEventListener('click', handleGameButtonClick);
ui.elements.setupButton.addEventListener('click', handleSetupButtonClick);
ui.elements.addPlayerButton.addEventListener('click', handleAddPlayerButtonClick);
ui.elements.resetButton.addEventListener('click', handleResetButtonClick);
ui.elements.playerForm.addEventListener('submit', handlePlayerFormSubmit);
ui.elements.cancelButton.addEventListener('click', handlePlayerModalCancel);
ui.elements.deletePlayerButton.addEventListener('click', handleDeletePlayer);
ui.elements.resetConfirmButton.addEventListener('click', handleResetConfirm);
ui.elements.resetCancelButton.addEventListener('click', handleResetCancel);
ui.elements.cameraButton.addEventListener('click', handleCameraButtonClick);
// 6. Setup Service Worker (which also initializes Flic)
setupServiceWorker();
// 7. Initial UI Update based on loaded state
ui.renderPlayers();
ui.updateGameButton();
// 8. Resume timer if state loaded as 'running' (e.g., after refresh)
// Note: This might be undesirable. Usually, a refresh should pause.
// Consider forcing state to 'paused' or 'setup' on load?
// For now, let's reset running state to paused on load.
if (state.getGameState() === config.GAME_STATES.RUNNING) {
console.log("Game was running on load, setting to paused.");
state.setGameState(config.GAME_STATES.PAUSED);
ui.updateGameButton();
ui.renderPlayers();
}
console.log("App Initialized.");
}
// --- Start the application ---
initialize();