clean up
55
.dockerignore
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Version control
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.editorconfig
|
||||||
|
.eslintrc
|
||||||
|
.stylelintrc
|
||||||
|
.prettierrc
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
CHANGELOG.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
__tests__/
|
||||||
|
test/
|
||||||
|
tests/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Project specific files
|
||||||
|
screenshots/
|
||||||
|
labels.example
|
||||||
|
virt-game-timer.service
|
||||||
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
@@ -4,8 +4,9 @@ FROM nginx:alpine
|
|||||||
# Remove the default Nginx static files
|
# Remove the default Nginx static files
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
# Copy all your app's files into the Nginx directory
|
# Copy public directory contents into the Nginx directory
|
||||||
COPY . /usr/share/nginx/html
|
COPY public/ /usr/share/nginx/html/
|
||||||
|
COPY src/ /usr/share/nginx/html/src/
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
41
README.md
@@ -1,6 +1,31 @@
|
|||||||
# Game Timer
|
# Game Timer
|
||||||
|
|
||||||
Multi-player chess timer with carousel navigation
|
Multi-player game-timer timer with carousel navigation
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
game-timer/
|
||||||
|
├── docs/ # Documentation and project resources
|
||||||
|
│ └── screenshots/ # Application screenshots
|
||||||
|
├── public/ # Static assets and public-facing resources
|
||||||
|
│ ├── css/ # CSS stylesheets
|
||||||
|
│ ├── images/ # Images used by the application
|
||||||
|
│ ├── icons/ # App icons for PWA
|
||||||
|
│ ├── audio/ # Audio files
|
||||||
|
│ ├── index.html # Main HTML entry point
|
||||||
|
│ ├── manifest.json # PWA manifest
|
||||||
|
│ └── sw.js # Service Worker
|
||||||
|
├── src/ # Source code
|
||||||
|
│ └── js/ # JavaScript files
|
||||||
|
│ ├── core/ # Core application logic
|
||||||
|
│ ├── ui/ # UI-related code
|
||||||
|
│ ├── services/ # External services integration
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
├── Dockerfile # Docker container definition
|
||||||
|
├── .dockerignore # Files to exclude from Docker build
|
||||||
|
└── package.json # Project metadata and dependencies
|
||||||
|
```
|
||||||
|
|
||||||
# PWA Containerized Deployment
|
# PWA Containerized Deployment
|
||||||
|
|
||||||
@@ -23,7 +48,7 @@ git clone https://gitea.virtonline.eu/2HoursProject/game-timer.git
|
|||||||
cd game-timer
|
cd game-timer
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Build the docker image
|
### 2. Build the Docker image
|
||||||
|
|
||||||
From the repository root, run the following command to build your Docker image:
|
From the repository root, run the following command to build your Docker image:
|
||||||
|
|
||||||
@@ -56,7 +81,7 @@ docker logs game-timer-container
|
|||||||
After running the container, open your web browser and navigate to:
|
After running the container, open your web browser and navigate to:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
http://localhost
|
http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Terminate
|
### 5. Terminate
|
||||||
@@ -66,3 +91,13 @@ To stop your running game-timer-container, use:
|
|||||||
```bash
|
```bash
|
||||||
docker stop game-timer-container
|
docker stop game-timer-container
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For local development without Docker, you can use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start a local development server and open the application in your browser.
|
||||||
446
app.js
@@ -1,446 +0,0 @@
|
|||||||
// 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();
|
|
||||||
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 416 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"name": "countdown",
|
"name": "countdown",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Multi-player chess timer with carousel navigation",
|
"description": "Multi-player chess timer with carousel navigation",
|
||||||
"main": "app.js",
|
"main": "src/js/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "docker run -d -p 8080:80 --name game-timer-container game-timer:latest",
|
"start": "docker run -d -p 8080:80 --name game-timer-container game-timer:latest",
|
||||||
"stop": "docker stop game-timer-container && docker rm game-timer-container",
|
"stop": "docker stop game-timer-container && docker rm game-timer-container",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"rebuild": "npm run stop || true && npm run build && npm run start",
|
"rebuild": "npm run stop || true && npm run build && npm run start",
|
||||||
"logs": "docker logs game-timer-container",
|
"logs": "docker logs game-timer-container",
|
||||||
"status": "docker ps | grep game-timer-container",
|
"status": "docker ps | grep game-timer-container",
|
||||||
"dev": "cd /usr/share/nginx/html && python -m http.server 8000",
|
"dev": "npx http-server . -o /public",
|
||||||
"clean": "docker system prune -f"
|
"clean": "docker system prune -f"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 398 B |
|
Before Width: | Height: | Size: 770 B After Width: | Height: | Size: 770 B |
@@ -7,7 +7,7 @@
|
|||||||
<title>Game Timer</title>
|
<title>Game Timer</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -102,10 +102,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main application script -->
|
<!-- Main application script -->
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="../src/js/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
navigator.serviceWorker.register("/sw.js")
|
navigator.serviceWorker.register("sw.js")
|
||||||
.then(() => console.log("Service Worker Registered"))
|
.then(() => console.log("Service Worker Registered"))
|
||||||
.catch((err) => console.log("Service Worker Failed", err));
|
.catch((err) => console.log("Service Worker Failed", err));
|
||||||
}
|
}
|
||||||
@@ -2,46 +2,46 @@
|
|||||||
"name": "Game Timer PWA",
|
"name": "Game Timer PWA",
|
||||||
"short_name": "Game Timer",
|
"short_name": "Game Timer",
|
||||||
"description": "Multi-player chess-like timer with carousel navigation",
|
"description": "Multi-player chess-like timer with carousel navigation",
|
||||||
"start_url": "/index.html",
|
"start_url": "./index.html",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f5f5f5",
|
"background_color": "#f5f5f5",
|
||||||
"theme_color": "#2c3e50",
|
"theme_color": "#2c3e50",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/android-chrome-192x192.png",
|
"src": "./icons/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/android-chrome-512x512.png",
|
"src": "./icons/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/apple-touch-icon.png",
|
"src": "./icons/apple-touch-icon.png",
|
||||||
"sizes": "180x180",
|
"sizes": "180x180",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/favicon-32x32.png",
|
"src": "./icons/favicon-32x32.png",
|
||||||
"sizes": "32x32",
|
"sizes": "32x32",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/favicon-16x16.png",
|
"src": "./icons/favicon-16x16.png",
|
||||||
"sizes": "16x16",
|
"sizes": "16x16",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"screenshots": [
|
"screenshots": [
|
||||||
{
|
{
|
||||||
"src": "/screenshots/screenshot1.png",
|
"src": "../docs/screenshots/screenshot1.png",
|
||||||
"sizes": "2604x2269",
|
"sizes": "2604x2269",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"form_factor": "wide"
|
"form_factor": "wide"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/screenshots/screenshot2.png",
|
"src": "../docs/screenshots/screenshot2.png",
|
||||||
"sizes": "1082x2402",
|
"sizes": "1082x2402",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
"short_name": "Game Timer",
|
"short_name": "Game Timer",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/android-chrome-192x192.png",
|
"src": "./icons/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/android-chrome-512x512.png",
|
"src": "./icons/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
@@ -4,17 +4,17 @@ const CACHE_NAME = `game-timer-${CACHE_VERSION}`;
|
|||||||
|
|
||||||
// Files to cache
|
// Files to cache
|
||||||
const CACHE_FILES = [
|
const CACHE_FILES = [
|
||||||
'/',
|
'./',
|
||||||
'/index.html',
|
'./index.html',
|
||||||
'/app.js',
|
'../src/js/app.js',
|
||||||
'/audio.js',
|
'../src/js/ui/audio.js',
|
||||||
'/styles.css',
|
'./css/styles.css',
|
||||||
'/manifest.json',
|
'./manifest.json',
|
||||||
'/icons/android-chrome-192x192.png',
|
'./icons/android-chrome-192x192.png',
|
||||||
'/icons/android-chrome-512x512.png',
|
'./icons/android-chrome-512x512.png',
|
||||||
'/icons/apple-touch-icon.png',
|
'./icons/apple-touch-icon.png',
|
||||||
'/icons/favicon-32x32.png',
|
'./icons/favicon-32x32.png',
|
||||||
'/icons/favicon-16x16.png'
|
'./icons/favicon-16x16.png'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - Cache files
|
// Install event - Cache files
|
||||||
@@ -124,7 +124,7 @@ self.addEventListener('push', event => {
|
|||||||
// If no window is open, we MUST show a notification
|
// If no window is open, we MUST show a notification
|
||||||
return self.registration.showNotification(pushData.title, {
|
return self.registration.showNotification(pushData.title, {
|
||||||
body: pushData.body,
|
body: pushData.body,
|
||||||
icon: '/icons/icon-192x192.png', // Optional: path to an icon
|
icon: './icons/android-chrome-192x192.png', // Optional: path to an icon
|
||||||
data: pushData.data // Pass data if needed when notification is clicked
|
data: pushData.data // Pass data if needed when notification is clicked
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ self.addEventListener('push', event => {
|
|||||||
if (!messageSent) { // Only show notification if no message was sent? Or always show?
|
if (!messageSent) { // Only show notification if no message was sent? Or always show?
|
||||||
return self.registration.showNotification(pushData.title, {
|
return self.registration.showNotification(pushData.title, {
|
||||||
body: pushData.body,
|
body: pushData.body,
|
||||||
icon: '/icons/icon-192x192.png',
|
icon: './icons/android-chrome-192x192.png',
|
||||||
data: pushData.data
|
data: pushData.data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ self.addEventListener('push', event => {
|
|||||||
/*
|
/*
|
||||||
const notificationOptions = {
|
const notificationOptions = {
|
||||||
body: pushData.body,
|
body: pushData.body,
|
||||||
icon: '/icons/icon-192x192.png', // Optional: path to an icon
|
icon: './icons/android-chrome-192x192.png', // Optional: path to an icon
|
||||||
data: pushData.data // Attach data if needed when notification is clicked
|
data: pushData.data // Attach data if needed when notification is clicked
|
||||||
};
|
};
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
@@ -185,7 +185,7 @@ self.addEventListener('notificationclick', event => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (self.clients.openWindow) {
|
if (self.clients.openWindow) {
|
||||||
return self.clients.openWindow('/');
|
return self.clients.openWindow('./');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
91
src/js/app.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// app.js - Main Application Orchestrator
|
||||||
|
import * as config from './config.js';
|
||||||
|
import * as state from './core/state.js';
|
||||||
|
import * as ui from './ui/ui.js';
|
||||||
|
import * as timer from './core/timer.js';
|
||||||
|
import camera from './ui/camera.js'; // Default export
|
||||||
|
import audioManager from './ui/audio.js';
|
||||||
|
import * as pushFlic from './services/pushFlicIntegration.js';
|
||||||
|
|
||||||
|
// Import externalized modules
|
||||||
|
import * as gameActions from './core/gameActions.js';
|
||||||
|
import * as playerManager from './core/playerManager.js';
|
||||||
|
import * as eventHandlers from './core/eventHandlers.js';
|
||||||
|
import * as serviceWorkerManager from './services/serviceWorkerManager.js';
|
||||||
|
|
||||||
|
// --- 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) playerManager.nextPlayer(); else playerManager.previousPlayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Initialize Timer (pass callbacks for UI updates/state changes)
|
||||||
|
timer.initTimer({
|
||||||
|
onTimerTick: eventHandlers.handleTimerTick,
|
||||||
|
onPlayerSwitch: eventHandlers.handlePlayerSwitchOnTimer,
|
||||||
|
onGameOver: gameActions.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: eventHandlers.handleCameraCapture
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Set up UI Event Listeners that trigger actions
|
||||||
|
ui.elements.gameButton.addEventListener('click', eventHandlers.handleGameButtonClick);
|
||||||
|
ui.elements.setupButton.addEventListener('click', eventHandlers.handleSetupButtonClick);
|
||||||
|
ui.elements.addPlayerButton.addEventListener('click', eventHandlers.handleAddPlayerButtonClick);
|
||||||
|
ui.elements.resetButton.addEventListener('click', eventHandlers.handleResetButtonClick);
|
||||||
|
ui.elements.playerForm.addEventListener('submit', playerManager.handlePlayerFormSubmit);
|
||||||
|
ui.elements.cancelButton.addEventListener('click', eventHandlers.handlePlayerModalCancel);
|
||||||
|
ui.elements.deletePlayerButton.addEventListener('click', playerManager.handleDeletePlayer);
|
||||||
|
ui.elements.resetConfirmButton.addEventListener('click', eventHandlers.handleResetConfirm);
|
||||||
|
ui.elements.resetCancelButton.addEventListener('click', eventHandlers.handleResetCancel);
|
||||||
|
ui.elements.cameraButton.addEventListener('click', eventHandlers.handleCameraButtonClick);
|
||||||
|
|
||||||
|
// 6. Setup Flic action handlers
|
||||||
|
const flicActionHandlers = {
|
||||||
|
[config.FLIC_ACTIONS.SINGLE_CLICK]: playerManager.nextPlayer,
|
||||||
|
[config.FLIC_ACTIONS.DOUBLE_CLICK]: playerManager.previousPlayer,
|
||||||
|
[config.FLIC_ACTIONS.HOLD]: gameActions.togglePauseResume,
|
||||||
|
};
|
||||||
|
serviceWorkerManager.setFlicActionHandlers(flicActionHandlers);
|
||||||
|
|
||||||
|
// 7. Setup Service Worker (which also initializes Flic)
|
||||||
|
serviceWorkerManager.setupServiceWorker(serviceWorkerManager.handleServiceWorkerMessage);
|
||||||
|
|
||||||
|
// 8. Initial UI Update based on loaded state
|
||||||
|
ui.renderPlayers();
|
||||||
|
ui.updateGameButton();
|
||||||
|
|
||||||
|
// 9. 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();
|
||||||
92
src/js/core/eventHandlers.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// eventHandlers.js - UI event handlers
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as state from './state.js';
|
||||||
|
import * as ui from '../ui/ui.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
import camera from '../ui/camera.js';
|
||||||
|
import { togglePauseResume, fullResetApp } from './gameActions.js';
|
||||||
|
import { handlePlayerFormSubmit, handleDeletePlayer } from './playerManager.js';
|
||||||
|
|
||||||
|
export function handleGameButtonClick() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
togglePauseResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleResetButtonClick() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
alert('Please pause the game before resetting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.showResetModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePlayerModalCancel() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
ui.hidePlayerModal();
|
||||||
|
camera.stopStream(); // Make sure camera turns off
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleResetConfirm() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
fullResetApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleResetCancel() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
ui.hideResetModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleCameraButtonClick(event) {
|
||||||
|
event.preventDefault(); // Prevent form submission if inside form
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
camera.open(); // Open the camera interface
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timer Callbacks ---
|
||||||
|
export function handleTimerTick() {
|
||||||
|
// Timer module already updated the state, just need to redraw UI
|
||||||
|
ui.renderPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePlayerSwitchOnTimer(newPlayerIndex) {
|
||||||
|
// Timer detected current player ran out, found next player
|
||||||
|
console.log(`Timer switching to player index: ${newPlayerIndex}`);
|
||||||
|
// Import switchToPlayer dynamically to avoid circular dependency
|
||||||
|
import('./playerManager.js').then(module => {
|
||||||
|
module.switchToPlayer(newPlayerIndex);
|
||||||
|
});
|
||||||
|
// Sound is handled in switchToPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Camera Callback ---
|
||||||
|
export function handleCameraCapture(imageDataUrl) {
|
||||||
|
console.log("Image captured");
|
||||||
|
ui.updateImagePreviewFromDataUrl(imageDataUrl);
|
||||||
|
// Camera module already closed the camera UI
|
||||||
|
}
|
||||||
91
src/js/core/gameActions.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// gameActions.js - Core game action functions
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as state from './state.js';
|
||||||
|
import * as ui from '../ui/ui.js';
|
||||||
|
import * as timer from './timer.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
|
||||||
|
// --- Core Game Actions ---
|
||||||
|
|
||||||
|
// Declare handleGameOver at the top level to avoid referencing before definition
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullResetApp() {
|
||||||
|
timer.stopTimer();
|
||||||
|
state.resetToDefaults();
|
||||||
|
audioManager.play('gameOver'); // Use game over sound for full reset
|
||||||
|
ui.hideResetModal();
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers();
|
||||||
|
}
|
||||||
154
src/js/core/playerManager.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// playerManager.js - Player-related operations
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as state from './state.js';
|
||||||
|
import * as ui from '../ui/ui.js';
|
||||||
|
import * as timer from './timer.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
import camera from '../ui/camera.js';
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 : (isNewPlayer ? null : 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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();
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// state.js
|
// state.js
|
||||||
import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from './config.js';
|
import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
|
||||||
|
|
||||||
let players = [];
|
let players = [];
|
||||||
let currentPlayerIndex = 0;
|
let currentPlayerIndex = 0;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// timer.js
|
// timer.js
|
||||||
import * as state from './state.js';
|
import * as state from './state.js';
|
||||||
import { GAME_STATES } from './config.js';
|
import { GAME_STATES } from '../config.js';
|
||||||
import audioManager from './audio.js';
|
import audioManager from '../ui/audio.js';
|
||||||
|
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
let onTimerTickCallback = null; // Callback for UI updates
|
let onTimerTickCallback = null; // Callback for UI updates
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// pushFlicIntegration.js
|
// pushFlicIntegration.js
|
||||||
import { PUBLIC_VAPID_KEY, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS, FLIC_BATTERY_THRESHOLD } from './config.js';
|
import { PUBLIC_VAPID_KEY, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS, FLIC_BATTERY_THRESHOLD } from '../config.js';
|
||||||
|
|
||||||
let pushSubscription = null; // Keep track locally if needed
|
let pushSubscription = null; // Keep track locally if needed
|
||||||
let actionHandlers = {}; // Store handlers for different Flic actions
|
let actionHandlers = {}; // Store handlers for different Flic actions
|
||||||
@@ -88,7 +88,7 @@ function showBatteryWarning(batteryLevel) {
|
|||||||
if (Notification.permission === 'granted') {
|
if (Notification.permission === 'granted') {
|
||||||
new Notification('Flic Button Low Battery', {
|
new Notification('Flic Button Low Battery', {
|
||||||
body: message,
|
body: message,
|
||||||
icon: '/favicon.ico'
|
icon: '/public/favicon.ico'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
58
src/js/services/serviceWorkerManager.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// serviceWorkerManager.js - Service worker registration and Flic integration
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as pushFlic from './pushFlicIntegration.js';
|
||||||
|
|
||||||
|
// Store the action handlers passed from app.js
|
||||||
|
let flicActionHandlers = {};
|
||||||
|
|
||||||
|
export function setFlicActionHandlers(handlers) {
|
||||||
|
flicActionHandlers = handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Flic Integration Setup ---
|
||||||
|
export function initFlic() {
|
||||||
|
// This function is used by setupServiceWorker and relies on
|
||||||
|
// flicActionHandlers being set before this is called
|
||||||
|
pushFlic.initPushFlic(flicActionHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
if (flicActionHandlers[action]) {
|
||||||
|
flicActionHandlers[action]();
|
||||||
|
} else {
|
||||||
|
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Service Worker and PWA Setup ---
|
||||||
|
export function setupServiceWorker(messageHandler) {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/public/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('ServiceWorker registered successfully.');
|
||||||
|
|
||||||
|
// Listen for messages FROM the Service Worker (e.g., Flic actions)
|
||||||
|
navigator.serviceWorker.addEventListener('message', messageHandler);
|
||||||
|
|
||||||
|
// 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// camera.js
|
// camera.js
|
||||||
import { CSS_CLASSES } from './config.js';
|
import { CSS_CLASSES } from '../config.js';
|
||||||
|
|
||||||
let stream = null;
|
let stream = null;
|
||||||
let elements = {}; // To store references to DOM elements passed during init
|
let elements = {}; // To store references to DOM elements passed during init
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// ui.js
|
// ui.js
|
||||||
import * as state from './state.js';
|
import * as state from '../core/state.js';
|
||||||
import { GAME_STATES, CSS_CLASSES } from './config.js';
|
import { GAME_STATES, CSS_CLASSES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
|
||||||
import audioManager from './audio.js';
|
import audioManager from './audio.js';
|
||||||
|
|
||||||
// --- DOM Elements ---
|
// --- DOM Elements ---
|
||||||
@@ -118,8 +118,9 @@ export function showPlayerModal(isNewPlayer, player = null) {
|
|||||||
if (isNewPlayer) {
|
if (isNewPlayer) {
|
||||||
elements.modalTitle.textContent = 'Add New Player';
|
elements.modalTitle.textContent = 'Add New Player';
|
||||||
elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`;
|
elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`;
|
||||||
elements.playerTimeInput.value = state.DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time
|
elements.playerTimeInput.value = DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time from config
|
||||||
elements.remainingTimeContainer.style.display = 'none';
|
elements.remainingTimeContainer.style.display = 'none';
|
||||||
|
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible for new players
|
||||||
elements.imagePreview.innerHTML = '<i class="fas fa-user"></i>';
|
elements.imagePreview.innerHTML = '<i class="fas fa-user"></i>';
|
||||||
elements.deletePlayerButton.style.display = 'none';
|
elements.deletePlayerButton.style.display = 'none';
|
||||||
} else if (player) {
|
} else if (player) {
|
||||||
@@ -129,11 +130,13 @@ export function showPlayerModal(isNewPlayer, player = null) {
|
|||||||
|
|
||||||
if (currentGameState === GAME_STATES.PAUSED || currentGameState === GAME_STATES.OVER) {
|
if (currentGameState === GAME_STATES.PAUSED || currentGameState === GAME_STATES.OVER) {
|
||||||
elements.remainingTimeContainer.style.display = 'block';
|
elements.remainingTimeContainer.style.display = 'block';
|
||||||
|
elements.playerTimeContainer.style.display = 'none'; // Hide Time field when Remaining Time is shown
|
||||||
const minutes = Math.floor(player.remainingTime / 60);
|
const minutes = Math.floor(player.remainingTime / 60);
|
||||||
const seconds = player.remainingTime % 60;
|
const seconds = player.remainingTime % 60;
|
||||||
elements.playerRemainingTimeInput.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
elements.playerRemainingTimeInput.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
} else {
|
} else {
|
||||||
elements.remainingTimeContainer.style.display = 'none';
|
elements.remainingTimeContainer.style.display = 'none';
|
||||||
|
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible otherwise
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.imagePreview.innerHTML = player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>';
|
elements.imagePreview.innerHTML = player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>';
|
||||||