446 lines
16 KiB
JavaScript
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(); |