1
0
forked from jchomaz/Vulture

58 Commits

Author SHA1 Message Date
332098a6fd (update) mise à jour des commentaires en francais 2026-02-03 19:59:02 +01:00
df2c9d4788 (new) track the new quizstore.js for manage the current Vulture Session 2026-02-01 16:20:49 +01:00
2fe8527c37 (update) track the time in the new real timer for the remaining time and new uix placements 2026-02-01 16:19:39 +01:00
5938e269e1 (update) track the time in the new real timer for the remaining time 2026-02-01 16:18:59 +01:00
ff03299645 (update) track the time in the new real timer for the remaining time 2026-02-01 16:18:18 +01:00
5624336173 (new) add the new media manager in the GameDisplay 2026-02-01 16:17:10 +01:00
7aa5ddb4ec (update) add buzzer blocked action in hiding overlay 2026-02-01 16:16:22 +01:00
be8c18710d (update) add info card about the current question for the game master 2026-02-01 16:15:28 +01:00
f4530e8e50 (new) add info card about the current question for the game master 2026-02-01 16:14:20 +01:00
8db6f16ac8 (update) add real timer from the current question 2026-02-01 16:12:21 +01:00
fb3b7fabd4 Modification du thème de la fenêtre de validation de buzzer 2026-02-01 13:54:15 +01:00
0244854ddb patch MQTT 2026-02-01 13:53:36 +01:00
bcec23a751 Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:52:24 +01:00
70fb7cbcea Mise à jour des couleur et du thème 2026-02-01 13:51:57 +01:00
353541541d Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:50:25 +01:00
ee4c2604db Retrait des anciens fichiers de configuration 2026-02-01 13:49:54 +01:00
ad9b29ca93 Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:49:11 +01:00
7413a2a78f Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:48:53 +01:00
54bbfa00b3 Ajout de la nouvelle page 2026-02-01 13:48:02 +01:00
de8f8f051f Patch des soucis de passage d'une page à une autre 2026-02-01 13:43:09 +01:00
f855601217 update the configuration file to be in unique file 2026-02-01 13:41:09 +01:00
ddbd00ae3f Update components and remove BrainBlastBar.vue -> VultureBar.vue 2026-02-01 13:39:35 +01:00
184a6ac600 config : corrige ip buzzer 2026-01-26 17:32:29 +01:00
9a4a2cb6ad Change le port exposé de l'app 2026-01-26 17:13:08 +01:00
3a458be33d feat: Refactor VApp container to a development build and update its exposed port mapping in the run script. 2026-01-26 17:13:08 +01:00
4e0f34f75c chore: Update buzzer IP addresses in network configuration. 2026-01-26 17:13:08 +01:00
19fe61f077 feat: Introduce Quadlet units for dev/prod VApp containers and a custom network, update existing container configurations, and enhance documentation. 2026-01-26 17:13:07 +01:00
b86909c744 Créer 2 scripts de lancement un pour le dev en local l'autre pour la prod 2026-01-26 17:13:07 +01:00
4e57c70b3c gestion d'une config pour les ip si prod ou dev 2026-01-26 17:13:07 +01:00
da932bdcb3 Lancement en mode network plutot que pod 2026-01-26 17:13:02 +01:00
6026bfb7ff Quadlet (WiP) 2026-01-26 17:09:46 +01:00
28a05c2104 scripts de gestion 2026-01-26 17:09:46 +01:00
b9a2c53032 Correction des options de lancement 2026-01-26 17:09:46 +01:00
c73322a67a Mise à jour des paquets 2026-01-23 18:52:04 +01:00
5900b1faa1 Ajout des boutons de simulation de buzzer et agrandissement de la fenêtre 2026-01-23 18:50:58 +01:00
af58e9c30d Agrandissement de la fenêtre de debug 2026-01-23 18:49:50 +01:00
bc8846d9eb Nouveau player video du game display 2026-01-23 18:48:41 +01:00
5c16468157 Page d'overlay qui masque le player 2026-01-23 18:48:13 +01:00
911671c653 Nouvelle page qui s'affiche en popup sur la page de controle 2026-01-23 18:47:44 +01:00
cd540698a1 Modification de l'équipe Orange en jaune 2026-01-23 18:47:01 +01:00
2a28526cb9 Modification de l'équipe Orange en jaune 2026-01-23 18:45:55 +01:00
6666874913 Modification du controle manuel des score et suppression des 16 bouttons 2026-01-23 18:44:35 +01:00
814c3d0e68 Ajout du popup quand les équipes buzz 2026-01-23 18:43:56 +01:00
911497ab1d Ajout des scores dans les pastilles d'équipes et de le mise à jour en live des scores 2026-01-23 18:42:42 +01:00
905da933dc Modification du score-manager 2026-01-23 18:40:44 +01:00
0186a0a83e Modification du buzzer-manager 2026-01-23 18:40:23 +01:00
da929ecb89 Merge pull request 'container-integration' (#1) from lol/Vulture:container-integration into main
Reviewed-on: jchomaz/Vulture#1
2025-11-24 17:47:13 +01:00
4ab0cca1b2 (conf) VApp : configuration de la bonne adresse du serveur mqtt 2025-11-24 17:40:00 +01:00
5401f416e7 (chore) VNode: ignore les json dans le répertoire des scores 2025-11-16 19:07:45 +01:00
856b90567d (config) VNode : Utilise localhost comme adresse du serveur mqtt
On est sur le même pod.
Voir si besoin de faire une variable d'env ou autre si besoin de
spécifier pour le dev
2025-11-16 19:05:59 +01:00
bb791ed2f4 (feat) VContainers : build VNode 2025-11-16 19:05:59 +01:00
768f42dff4 (feat) VContainers : build VApp 2025-11-16 19:05:59 +01:00
bc807f9c7b (feat) VContainers : Fichier de configuration pour nanomq 2025-11-16 19:05:54 +01:00
8d980a90c8 (doc) VContainers : document création et lancement de container 2025-11-16 19:05:49 +01:00
e8607c78e9 (fix) Vapp : Fuck***g caseless filesystem 2025-11-16 19:05:49 +01:00
1ff31e0991 (fix) VNode: portable path 2025-11-16 19:05:42 +01:00
3e29bfca18 (chore) VApp : Update dependencies 2025-11-16 19:05:41 +01:00
56c6a744c7 (chore) All: Ajoute .gitignore générique pour js/vue/... 2025-11-16 19:05:28 +01:00
54 changed files with 4344 additions and 2140 deletions

142
.gitignore vendored Normal file
View File

@@ -0,0 +1,142 @@
# Score files
/VNode/services/game/score/*.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <meta charset="UTF-8"> <link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Brain Blast</title> <head> <meta charset="UTF-8"> <link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Brain Blast</title>
<script src="/config.js"></script>
</head> </head>
<body> <div id="app"></div> <script type="module" src="/src/main.js"></script> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script>
</body> </body>

3590
VApp/package-lock.json generated

File diff suppressed because it is too large Load Diff

7
VApp/public/config.js Normal file
View File

@@ -0,0 +1,7 @@
window.APP_CONFIG = {
mqttBrokerUrl: 'ws://192.168.73.252:9001',
redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43'
};

View File

@@ -1,19 +1,28 @@
<template> <template>
<v-app> <v-app>
<BrainBlastBar v-if="$route.name != 'Game Display (Projection)'" /> <VultureBar v-if="showVultureBar" />
<GameStatus v-if="$route.name === 'Game Control (Présentateur)'"> <GameStatus v-if="showGameStatus" />
</GameStatus>
<v-main> <v-main>
<RouterView /> <RouterView :key="$route.fullPath" />
</v-main> <!-- <v-footer class="footer" :elevation=12 border><v-row justify="center">© 2024 - ASCO section Fablab</v-row></v-footer> --> </v-main> <!-- <v-footer class="footer" :elevation=12 border><v-row justify="center">© 2024 - ASCO section Fablab</v-row></v-footer> -->
</v-app> </v-app>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue';
import BrainBlastBar from '@/components/BrainBlastBar.vue' import { useRoute } from 'vue-router';
import VultureBar from '@/components/VultureBar.vue'
import GameStatus from '@/components/GameStatus.vue' import GameStatus from '@/components/GameStatus.vue'
const route = useRoute();
const showVultureBar = computed(() => {
return route.name !== 'Game Display (Projection)' && route.name !== 'Score Display (Projection)';
});
const showGameStatus = computed(() => {
return route.name === 'Game Control (Présentateur)';
});
</script> </script>
<style> <style>

View File

Before

Width:  |  Height:  |  Size: 766 KiB

After

Width:  |  Height:  |  Size: 766 KiB

View File

@@ -1,10 +0,0 @@
<template>
<v-app-bar :collapse="$route.name === 'Game Display (Projection)'" :elevation="5" height="50">
<RouterMenu />
<v-app-bar-title v-if="$route.name !== 'Accueil'">Brain Blast</v-app-bar-title>
</v-app-bar>
</template>
<script setup>
import RouterMenu from '@/components/RouterMenu.vue'
</script>

View File

@@ -0,0 +1,146 @@
<template>
<v-dialog v-model="dialog" persistent max-width="800" height="500" style="background-color: rgba(0, 0, 0, 0.8);">
<v-card dark rounded="xl">
<v-card-title :style="{ backgroundColor: buzzerColor }" class="headline text-center justify-center">
<v-icon color="background" dark large left size="70">mdi-alarm-light</v-icon>
</v-card-title>
<v-card-text :style="{ color: buzzerColor }" class="text-style">
L'équipe {{ buzzerTeam }} a buzzé !
</v-card-text>
<v-card-actions class="justify-center pa-0 ma-0" style="height: 100px; gap: 0;">
<v-btn
class="refuse-btn ma-0"
tile
rounded="0"
height="100%"
width="50%"
@click="refuse">
<v-icon left size="40">mdi-close-circle</v-icon>
<span style="font-size: 20px; padding-left: 10px;">Refuser</span>
</v-btn>
<v-btn
class="validate-btn ma-0"
tile
rounded="0"
height="100%"
width="50%"
:style="{ backgroundColor: buzzerColor }"
@click="validate">
<v-icon left size="40">mdi-check-circle</v-icon>
<span style="font-size: 20px; padding-left: 10px;">Valider (+1)</span>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import mqtt from 'mqtt';
import config from '@/config.js';
import { useTheme } from 'vuetify';
const theme = useTheme();
const dialog = ref(false);
const buzzerTeam = ref('');
const buzzerColor = ref('');
const client = mqtt.connect(config.mqttBrokerUrl);
// Associe les couleurs hex aux noms d'équipe si besoin, ou utilise directement la couleur
function getTeamNameFromColor(color) {
const c = color.toUpperCase();
const colors = theme.current.value.colors;
console.log('Received Color:', c);
console.log('Comparing against:', colors.RedBuzzer.toUpperCase(), colors.BlueBuzzer.toUpperCase(), colors.YellowBuzzer.toUpperCase(), colors.GreenBuzzer.toUpperCase());
if (c === colors.RedBuzzer.toUpperCase()) return 'rouge';
if (c === colors.BlueBuzzer.toUpperCase()) return 'bleue';
if (c === colors.YellowBuzzer.toUpperCase()) return 'jaune';
if (c === colors.GreenBuzzer.toUpperCase()) return 'verte';
return color; // Valeur par défaut
}
function getTeamKeyFromColor(color) {
const c = color.toUpperCase();
const colors = theme.current.value.colors;
if (c === colors.RedBuzzer.toUpperCase()) return 'Red';
if (c === colors.BlueBuzzer.toUpperCase()) return 'Blue';
if (c === colors.YellowBuzzer.toUpperCase()) return 'Yellow';
if (c === colors.GreenBuzzer.toUpperCase()) return 'Green';
return null;
}
onMounted(() => {
client.on('connect', () => {
console.log('BuzzerValidation: Connected');
client.subscribe('vulture/buzzer/status');
});
client.on('message', (topic, message) => {
if (topic === 'vulture/buzzer/status') {
try {
const data = JSON.parse(message.toString());
if (data.status === 'blocked') {
buzzerColor.value = data.color;
buzzerTeam.value = getTeamNameFromColor(data.color);
dialog.value = true;
} else if (data.status === 'unblocked') {
// Optionnel : fermer automatiquement si débloqué depuis ailleurs
dialog.value = false;
}
} catch (e) {
console.error('Error parsing buzzer status:', e);
}
}
});
});
function unlockBuzzers() {
client.publish('vulture/buzzer/unlock','0');
}
function validate() {
const teamKey = getTeamKeyFromColor(buzzerColor.value);
if (teamKey) {
const payload = { [teamKey]: "+1" };
client.publish('game/score/update', JSON.stringify(payload));
}
// Petit délai avant le déblocage pour que la mise à jour du score soit traitée
setTimeout(() => {
unlockBuzzers();
}, 100);
dialog.value = false;
}
function refuse() {
unlockBuzzers();
dialog.value = false;
}
</script>
<style scoped>
.headline {
font-weight: bold;
}
.text-style {
font-size: 45px!important;
font-weight: 600;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
height: 100%; /* S'assure que l'élément occupe toute la hauteur du conteneur si possible, ou une hauteur substantielle */
}
.validate-btn {
color: rgb(var(--v-theme-background),1);
}
.refuse-btn {
background-color: rgb(var(--v-theme-inactiveButton),1);
color: rgb(var(--v-theme-background),1);
}
</style>

View File

@@ -4,111 +4,163 @@
<v-icon left class="white--text pr-5 pl-2" size="40">mdi-calculator-variant</v-icon> <v-icon left class="white--text pr-5 pl-2" size="40">mdi-calculator-variant</v-icon>
Gestion des scores Gestion des scores
</v-card-title> </v-card-title>
<v-container class="text-center"> <v-container class="text-center pt-8">
<v-row justify="center"> <!-- Team Lines -->
<v-col cols="4" sm="6" md="3"> <v-row v-for="(team, color) in scores" :key="color" align="center" justify="center" class="mb-2">
<mqtt-button width="120" height="60" class="btn red xs12 sm6 md3" topic="game/score/update" message='{"Red": "-2"}'> <!-- Icon/Label -->
<v-icon left size="40">mdi-minus-box-multiple</v-icon> <v-col cols="2" class="d-flex justify-center">
</mqtt-button> <v-icon :color="getTeamColor(color)" size="40">mdi-circle</v-icon>
</v-col> </v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn red card xs12 sm6 md3" topic="game/score/update" message='{"Red": "-1"}'> <!-- Total Score Input -->
<v-icon left size="40">mdi-minus-box</v-icon> <v-col cols="5">
</mqtt-button> <v-text-field
</v-col> v-model.number="team.Total"
<v-col cols="4" sm="6" md="3"> label="Total"
<mqtt-button width="120" height="60" class="btn red card xs12 sm6 md3" topic="game/score/update" message='{"Red": "+1"}'> type="number"
<v-icon left size="40">mdi-plus-box</v-icon> variant="outlined"
</mqtt-button> density="compact"
</v-col> hide-details
<v-col cols="4" sm="6" md="3"> prepend-inner-icon="mdi-minus"
<mqtt-button width="120" height="60" class="btn red card xs12 sm6 md3 " topic="game/score/update" message='{"Red": "+2"}'> append-inner-icon="mdi-plus"
<v-icon left size="40">mdi-plus-box-multiple</v-icon> :color="getTeamColor(color)"
</mqtt-button> :base-color="getTeamColor(color)"
</v-col> @click:prepend-inner="changeScore(color, 'Total', -1)"
<v-col cols="4" sm="6" md="3"> @click:append-inner="changeScore(color, 'Total', 1)"
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "-2"}'> @update:model-value="updateScore(color)"
<v-icon left size="40">mdi-minus-box-multiple</v-icon> class="centered-input"
</mqtt-button> readonly
</v-col> ></v-text-field>
<v-col cols="12" sm="6" md="3"> </v-col>
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "-1"}'>
<v-icon left size="40">mdi-minus-box</v-icon> <!-- Round Score Input -->
</mqtt-button> <v-col cols="5">
</v-col> <v-text-field
<v-col cols="4" sm="6" md="3"> v-model.number="team.Round"
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "+1"}'> label="Manche"
<v-icon left size="40">mdi-plus-box</v-icon> type="number"
</mqtt-button> variant="outlined"
</v-col> density="compact"
<v-col cols="12" sm="6" md="3"> hide-details
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "+2"}'> prepend-inner-icon="mdi-minus"
<v-icon left size="40">mdi-plus-box-multiple</v-icon> append-inner-icon="mdi-plus"
</mqtt-button> :color="getTeamColor(color)"
</v-col> :base-color="getTeamColor(color)"
<v-col cols="4" sm="6" md="3"> @click:prepend-inner="changeScore(color, 'Round', -1)"
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "-2"}'> @click:append-inner="changeScore(color, 'Round', 1)"
<v-icon left size="40">mdi-minus-box-multiple</v-icon> @update:model-value="updateScore(color)"
</mqtt-button> class="centered-input"
</v-col> readonly
<v-col cols="12" sm="6" md="3"> ></v-text-field>
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "-1"}'> </v-col>
<v-icon left size="40">mdi-minus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "+1"}'>
<v-icon left size="40">mdi-plus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="12" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "+2"}'>
<v-icon left size="40">mdi-plus-box-multiple</v-icon>
</mqtt-button>
</v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "-2"}'>
<v-icon left size="40">mdi-minus-box-multiple</v-icon>
</mqtt-button>
</v-col>
<v-col cols="12" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "-1"}'>
<v-icon left size="40">mdi-minus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "+1"}'>
<v-icon left size="40">mdi-plus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="12" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "2"}'>
<v-icon left size="40">mdi-plus-box-multiple</v-icon>
</mqtt-button>
</v-col>
</v-row> </v-row>
</v-container> </v-container>
</v-card> </v-card>
</template> </template>
<script setup> <script setup>
import MqttButton from './MqttButton.vue'; import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { ref } from 'vue'; import mqtt from 'mqtt';
import config from '@/config.js'; // Ensure correct path
// Variable pour contrôler l'état de la carte
const isCardReduced = ref(false); const isCardReduced = ref(false);
// Méthode pour basculer l'état de la carte const scores = reactive({
Red: { Total: 0, Round: 0 },
Blue: { Total: 0, Round: 0 },
Yellow: { Total: 0, Round: 0 },
Green: { Total: 0, Round: 0 },
});
// const client = mqtt.connect(config.mqttBrokerUrl);
let client = null;
onMounted(() => {
client = mqtt.connect(config.mqttBrokerUrl);
client.on('connect', () => {
console.log('CardButtonScore: Connected to MQTT broker at', config.mqttBrokerUrl);
client.subscribe('game/score');
});
client.on('error', (err) => {
console.error('CardButtonScore: MQTT Error:', err);
});
client.on('message', (topic, message) => {
if (topic === 'game/score') {
try {
const data = JSON.parse(message.toString());
console.log('CardButtonScore: Received score update:', data);
if (data && data.TEAM) {
Object.keys(scores).forEach(color => {
if (data.TEAM[color]) {
scores[color].Total = data.TEAM[color].TotalScore;
scores[color].Round = data.TEAM[color].RoundScore;
}
});
}
} catch (e) {
console.error("Error parsing score update:", e);
}
}
});
});
onUnmounted(() => {
if (client) {
client.end();
}
});
function toggleCardSize() { function toggleCardSize() {
isCardReduced.value = !isCardReduced.value; isCardReduced.value = !isCardReduced.value;
} }
function getTeamColor(color) {
if (color === 'Yellow') return '#D4D100'; // Custom yellow
if (color === 'Red') return '#d42828';
if (color === 'Blue') return '#2867d4';
if (color === 'Green') return '#28d42e';
return color.toLowerCase();
}
function changeScore(teamColor, field, delta) {
scores[teamColor][field] += delta;
updateScore(teamColor);
}
function updateScore(teamColor) {
const payload = {
[teamColor]: {
Total: scores[teamColor].Total,
Round: scores[teamColor].Round
}
};
console.log('CardButtonScore: Publishing update:', payload);
client.publish('game/score/update', JSON.stringify(payload));
}
</script> </script>
<style> <style>
.card--reduced { .card--reduced {
height: 56px; /* Réglez la hauteur réduite selon vos besoins */ height: 56px;
width: 170px; width: 60%; /* Adjusted width for layout */
overflow: hidden; overflow: hidden;
transition: height 0.3s ease-in-out; transition: height 0.3s ease-in-out;
} }
.centered-input input {
text-align: center;
}
.centered-input .v-field__label {
text-align: center;
}
/* Remove number spin buttons */
.centered-input input[type=number]::-webkit-inner-spin-button,
.centered-input input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style> </style>

View File

@@ -28,7 +28,12 @@ const quizzList = ref([]);
// Fonction pour mettre à jour la liste // Fonction pour mettre à jour la liste
const handleMessage = (topic, message) => { const handleMessage = (topic, message) => {
try { try {
quizzList.value = JSON.parse(message.toString()); const parsed = JSON.parse(message.toString());
if (Array.isArray(parsed)) {
quizzList.value = parsed;
} else {
console.warn('CardCurrentQuizz: Received non-array data', parsed);
}
} catch (error) { } catch (error) {
console.error('Erreur de parsing JSON:', error); console.error('Erreur de parsing JSON:', error);
} }

View File

@@ -53,14 +53,14 @@
<div> <div>
<v-label class="labelRoundScore-style pt-3">Manche</v-label> <v-label class="labelRoundScore-style pt-3">Manche</v-label>
<div> <div>
<v-label class="labelRoundScore-style">{{ scores.OrangeRoundScore }}</v-label> <v-label class="labelRoundScore-style">{{ scores.YellowRoundScore }}</v-label>
</div> </div>
</div> </div>
<v-divider color="background"/> <v-divider color="background"/>
<div> <div>
<v-label class="labelTotalScore-style pt-3">Total</v-label> <v-label class="labelTotalScore-style pt-3">Total</v-label>
<div> <div>
<v-label class="labelTotalScore-style pb-3">{{ scores.OrangeTotalScore }}</v-label> <v-label class="labelTotalScore-style pb-3">{{ scores.YellowTotalScore }}</v-label>
</div> </div>
</div> </div>
</v-col> </v-col>
@@ -103,11 +103,11 @@ const client = mqtt.connect(mqttBrokerUrl)
const scores = reactive({ const scores = reactive({
RedTotalScore: 0, // Propriétés réactives RedTotalScore: 0, // Propriétés réactives
BlueTotalScore: 0, // Propriétés réactives BlueTotalScore: 0, // Propriétés réactives
OrangeTotalScore: 0, // Propriétés réactives YellowTotalScore: 0, // Propriétés réactives
GreenTotalScore: 0, // Propriétés réactives GreenTotalScore: 0, // Propriétés réactives
RedRoundScore: 0, // Propriétés réactives RedRoundScore: 0, // Propriétés réactives
BlueRoundScore: 0, // Propriétés réactives BlueRoundScore: 0, // Propriétés réactives
OrangeRoundScore: 0, // Propriétés réactives YellowRoundScore: 0, // Propriétés réactives
GreenRoundScore: 0, // Propriétés réactives GreenRoundScore: 0, // Propriétés réactives
}); });
// Fonction pour traiter chaque message reçu et réinitialiser le timeout // Fonction pour traiter chaque message reçu et réinitialiser le timeout

View File

@@ -3,14 +3,36 @@
<v-card-title class="card__title primary" @click="toggleCardSize"> <v-card-title class="card__title primary" @click="toggleCardSize">
<v-icon left class="white--text pr-5 pl-2" size="40">mdi-play-network-outline</v-icon> <v-icon left class="white--text pr-5 pl-2" size="40">mdi-play-network-outline</v-icon>
Solution </v-card-title> Solution </v-card-title>
<v-container class="text-center"> <v-container class="text-center" v-if="currentQuestion">
<v-row justify="center"> <div class="text-h6 mb-2">Question {{ currentQuestionIndex + 1 }}</div>
<v-container class="text-center"> <!-- Utilisation de styles CSS personnalisés pour centrer l'image --> <div class="text-body-1 font-weight-bold mb-4">{{ currentQuestion.QuestionText }}</div>
<v-img width="450" src="@/assets/copilot-solution-FULL-HD.jpg" style="margin: 0 auto;">
</v-img> <v-divider class="mb-4"></v-divider>
</v-container>
</v-row> <v-btn
:color="showSolution ? 'red' : 'green'"
class="mb-4"
@click="showSolution = !showSolution"
>
{{ showSolution ? 'Masquer Solution' : 'Voir Solution' }}
</v-btn>
<v-slide-y-transition>
<div v-if="showSolution" class="solution-block">
<div class="text-h5 success--text mb-2">{{ currentQuestion.MasterData.CorrectAnswer }}</div>
<div class="text-body-2 grey--text text--lighten-1 mb-2">
<v-icon small>mdi-information</v-icon> {{ currentQuestion.MasterData.MasterNotes }}
</div>
<div class="text-body-2 info--text">
<v-icon small>mdi-help-circle</v-icon> {{ currentQuestion.MasterData.Help }}
</div>
</div>
</v-slide-y-transition>
</v-container> </v-container>
<v-container v-else class="text-center">
<div class="text-caption">Aucun quiz chargé ou fin du quiz.</div>
</v-container>
</v-card> </v-card>
</template> </template>
<style> <style>
@@ -34,13 +56,18 @@
</style> </style>
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import quizStore from '@/store/quizStore';
// Variable pour contrôler l'état de la carte // Variable pour contrôler l'état de la carte
const isCardReduced = ref(false); const isCardReduced = ref(false);
const showSolution = ref(false);
// Méthode pour basculer l'état de la carte // Méthode pour basculer l'état de la carte
function toggleCardSize() { function toggleCardSize() {
isCardReduced.value = !isCardReduced.value; isCardReduced.value = !isCardReduced.value;
} }
const currentQuestion = quizStore.getters.currentQuestion;
const currentQuestionIndex = quizStore.getters.currentQuestionIndex;
</script> </script>

View File

@@ -1,85 +1,39 @@
<template> <template>
<div class="container"> <div class="container">
<div class="timer"> <div class="timer">
<v-label color="primary" class="labelTime-style" >{{ formatTime }}</v-label> <v-label class="labelTime-style" >{{ formatTime }}</v-label>
</div> </div>
<v-row no-gutters justify="space-around" >
<v-btn class="buttons" color="primary" icon="mdi-play" @click="startTimer"></v-btn>
<v-btn color="primary" icon="mdi-pause" @click="pauseTimer"></v-btn>
<v-btn color="primary" icon="mdi-restart" @click="resetTimer"></v-btn>
</v-row>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onBeforeUnmount } from 'vue'; import { computed, watch } from 'vue';
import quizStore from '@/store/quizStore';
const timerActive = ref(false); const timer = quizStore.getters.timer;
const startTime = ref(null);
const currentTime = ref(null); watch(timer, (val) => {
const elapsedTime = ref(0); console.log('CardTimer: timer value changed', val);
}, { immediate: true });
const formatTime = computed(() => { const formatTime = computed(() => {
let seconds = Math.floor(elapsedTime.value / 1000); const seconds = timer.value % 60;
let minutes = Math.floor(seconds / 60); const minutes = Math.floor(timer.value / 60);
let hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
seconds = seconds % 60; minutes = minutes % 60; return `${pad(minutes % 60)}:${pad(seconds)}`;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; });
});
const pad = (number) => { const pad = (number) => {
return (number < 10 ? "0" : "") + number; return (number < 10 ? "0" : "") + number;
}; };
const startTimer = () => {
if (!timerActive.value) {
timerActive.value = true;
startTime.value = Date.now() - elapsedTime.value;
updateTimer(); }
};
const pauseTimer = () => {
if (timerActive.value) {
timerActive.value = false;
clearInterval(currentTime.value); }
};
const resetTimer = () => {
elapsedTime.value = 0;
timerActive.value = false;
clearInterval(currentTime.value);
};
const updateTimer = () => {
currentTime.value = setInterval(() => {elapsedTime.value = Date.now() - startTime.value; }, 1000);
};
onBeforeUnmount(() => { clearInterval(currentTime.value);
});
</script>
<script>
const startTimer = () => {
if (!timerActive.value) {
timerActive.value = true;
startTime.value = Date.now() - elapsedTime.value;
updateTimer(); } };
const pauseTimer = () => {
if (timerActive.value) {
timerActive.value = false;
clearInterval(currentTime.value); } };
const resetTimer = () => {
elapsedTime.value = 0;
timerActive.value = false;
clearInterval(currentTime.value); };
export { startTimer, pauseTimer, resetTimer };
</script> </script>
<style> <style>
.container { .container {
text-align: center; text-align: center;
margin-top: auto; /* Place le container en bas de son parent */ margin-top: auto;
margin-bottom: 1px; /* Marge en bas pour un espacement */ margin-bottom: 1px;
position: fixed; /* Le positionne de manière fixe */ position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@@ -89,12 +43,9 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
.labelTime-style { .labelTime-style {
font-size: 30px !important; font-size: 40px !important;
font-weight: 500; font-weight: 500;
color: #d42828 !important; color: #d42828 !important;
opacity: 90% !important; opacity: 90% !important;
} }
.buttons{
background-color: rgb(255, 255, 255);
}
</style> </style>

View File

@@ -0,0 +1,265 @@
<template>
<v-container v-show="gamehiding === false" class="player_video_div">
<div v-if="currentQuestion" style="width: 100%; height: 100%;">
<!-- LECTEUR VIDÉO -->
<div v-show="currentQuestion.Type === 'video'" style="width: 100%; height: 100%;">
<video ref="videoPlayer" class="video-js player_video" controls preload="auto">
</video>
</div>
<!-- AFFICHAGE IMAGE -->
<div v-if="currentQuestion.Type === 'picture'" style="width: 100%; height: 100%;">
<v-img
:src="getMediaUrl(currentQuestion.MediaUrl)"
:key="currentQuestion.QuestionId"
class="player_video"
></v-img>
</div>
<!-- LECTEUR AUDIO -->
<div v-if="currentQuestion.Type === 'audio'" class="audio-container player_video">
<div class="audio-visualizer">
<v-icon size="150" color="white" class="mb-4">mdi-music-circle</v-icon>
<span class="text-h2 white--text">ÉCOUTEZ</span>
</div>
<audio ref="audioPlayer" :src="getMediaUrl(currentQuestion.MediaUrl)" class="custom-audio"></audio>
</div>
</div>
</v-container>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import quizStore from '@/store/quizStore';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import { subscribeToTopic, publishMessage } from '@/services/mqttService';
// Accès au store
const currentQuestion = quizStore.getters.currentQuestion;
// Références du lecteur vidéo
const videoPlayer = ref(null);
let vjsPlayer = null;
// Références du lecteur audio
const audioPlayer = ref(null);
let gamehiding = ref(true);
// Méthodes
function getMediaUrl(relativePath) {
if (!relativePath) return '';
const cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
const url = new URL(`../quizz/vulture-session-2026-01/${cleanPath}`, import.meta.url).href;
console.log('GameMedia: Resolved URL:', { relativePath, url });
return url;
}
function initVideoPlayer() {
if (vjsPlayer) return; // Déjà initialisé
if (!videoPlayer.value) return; // DOM pas prêt
console.log('GameMedia: Initializing VideoJS');
vjsPlayer = videojs(videoPlayer.value, {
autoplay: false,
controls: false,
preload: 'auto',
fluid: true,
loop: false,
volume: 0,
}, () => {
console.log('GameMedia: VideoJS Ready');
// Si la question courante est une vidéo, la charger
if (currentQuestion.value && currentQuestion.value.Type === 'video') {
updateVideoSource();
}
// Masquer automatiquement à la fin de la vidéo
vjsPlayer.on('ended', () => {
console.log('GameMedia: Video ended, hiding');
gamehiding.value = true;
publishMessage('/display/control', 'hide');
});
});
}
function updateVideoSource() {
if (!vjsPlayer || !currentQuestion.value) return;
const url = getMediaUrl(currentQuestion.value.MediaUrl);
console.log('GameMedia: Loading Video Source', url);
vjsPlayer.src({ type: 'video/mp4', src: url });
// L'autoplay est géré par la commande MQTT 'play' maintenant
// if (currentQuestion.value.Settings?.AutoPlay) {
// vjsPlayer.play().catch(e => console.log('Autoplay blocked', e));
// }
}
// Observateurs
watch(currentQuestion, async (newVal, oldVal) => {
console.log('GameMedia: Question Changed', newVal);
// Arrêter d'abord tous les médias
if (vjsPlayer) {
vjsPlayer.pause();
}
if (audioPlayer.value) {
audioPlayer.value.pause();
}
// Rester masqué au changement de question jusqu'à la lecture
gamehiding.value = true;
publishMessage('/display/control', 'hide');
if (!newVal) return;
await nextTick(); // Attendre la mise à jour du DOM (v-if)
if (newVal.Type === 'video') {
if (!vjsPlayer) {
initVideoPlayer();
} else {
updateVideoSource();
}
} else if (newVal.Type === 'audio') {
// Chargement audio (pas d'autoplay)
setTimeout(() => {
if(audioPlayer.value){
console.log('GameMedia: Loading Audio');
audioPlayer.value.load();
// Masquer automatiquement à la fin de l'audio
audioPlayer.value.onended = () => {
console.log('GameMedia: Audio ended, hiding');
gamehiding.value = true;
publishMessage('/display/control', 'hide');
};
}
}, 100);
}
// Pour le type 'picture', rien à faire, vidéo/audio déjà en pause
}, { immediate: true });
// Cycle de vie
onMounted(async () => {
await nextTick();
if (currentQuestion.value?.Type === 'video') {
initVideoPlayer();
}
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
});
});
const handleMessage = (topic, message) => {
console.log('GameMedia: Received', topic, message);
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
console.log("▶️ GameMedia: Play");
// Only play the media relevant to this question type
if (currentQuestion.value?.Type === 'video' && vjsPlayer) {
vjsPlayer.play().catch(e => console.error("Error playing video:", e));
}
if (currentQuestion.value?.Type === 'audio' && audioPlayer.value) {
audioPlayer.value.play().catch(e => console.error("Error playing audio:", e));
}
if (currentQuestion.value?.Type === 'picture') {
// Démarrer le timer si PlayTime est configuré
const playTime = currentQuestion.value.Settings?.PlayTime;
if (playTime && playTime > 0) {
quizStore.actions.startTimer(playTime);
}
}
break;
case "pause":
gamehiding.value = true;
console.log("⏸️ GameMedia: Pause");
quizStore.actions.stopTimer();
if (vjsPlayer) {
vjsPlayer.pause();
}
if (audioPlayer.value) {
audioPlayer.value.pause();
}
break;
case "hide":
console.log("🛑 GameMedia: Hide");
gamehiding.value = true;
quizStore.actions.stopTimer();
break;
}
}
// Vérifier le statut du buzzer pour masquer automatiquement au buzz (comme HidingOverlay)
// Réplication de VideoPlayer qui n'avait que /display/control dans l'extrait fourni.
// Comportement optionnel selon les événements du système.
if (topic === 'vulture/buzzer/status') {
try {
const data = JSON.parse(message);
if (data.status === 'blocked') {
console.log("GameMedia: Buzzer Blocked -> Hiding");
gamehiding.value = true;
if (vjsPlayer) vjsPlayer.pause();
if (audioPlayer.value) audioPlayer.value.pause();
}
} catch (e) { console.error('JSON Error', e); }
}
};
onBeforeUnmount(() => {
if (vjsPlayer) {
vjsPlayer.dispose();
}
});
</script>
<style>
.player_video_div {
margin-top: 40px;
width: calc(100vw - 20%);
height: calc(100vh - 20%);
border-radius: 20px !important;
}
.player_video {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
border-radius: 25px !important;
}
.vjs-tech{
border-radius: 25px;
}
/* Styles additionnels pour les éléments Audio/Custom pour s'adapter au thème */
.audio-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(45deg, #1a1a1a, #2c3e50);
}
.audio-visualizer {
display: flex;
flex-direction: column;
align-items: center;
animation: pulse 2s infinite;
}
.text-h2 {
font-family: 'Bahnschrift', sans-serif !important;
}
.custom-audio {
margin-top: 30px;
width: 80%;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
100% { transform: scale(1); opacity: 0.8; }
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<v-container v-show="gamehiding === true" class="v-container-game-hided">
<v-img src="@\assets\v-hide.png" class="v-img-hidding"/>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { subscribeToTopic } from '@/services/mqttService';
let gamehiding = ref(true);
const handleMessage = (topic, message) => {
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
break;
case "pause":
gamehiding.value = true;
break;
case "hide":
gamehiding.value = true;
break;
default:
console.warn("Commande non reconnue :", message);
gamehiding.value = true;
}
}
};
// --- Lifecycle
onMounted(() => {
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
if (topic === 'vulture/buzzer/status') {
try {
const data = JSON.parse(message);
if (data.status === 'blocked') {
gamehiding.value = true;
}
} catch (e) {
console.error('HidingOverlay JSON error', e);
}
}
});
});
</script>
<style>
.v-img-hidding{
border-radius: 25px;
}
.v-container-game-hided{
margin-top: 40px;
width: calc(100vw - 20%) !important;
height: calc(100vh - 20%) !important;
border-radius: 25px;
}
</style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<v-container class="v-container-style-console"> <v-container class="v-container-style-console">
<v-card tile outlined width="500"> <v-card tile outlined width="900">
<v-card-title class="card__title primary centered-title"> <v-card-title class="card__title primary centered-title">
<v-icon left class="pr-5 pl-2" size="40">mdi-console-line</v-icon> <v-icon left class="pr-5 pl-2" size="40">mdi-console-line</v-icon>
Console MQTT Console MQTT
@@ -27,7 +27,7 @@
</div> </div>
</div> </div>
<v-container class="text-center"> <v-container>
<div v-for="(log, index) in filteredLogs" :key="index"> <div v-for="(log, index) in filteredLogs" :key="index">
<v-label class="v-label-timestamp">{{ log.timestamp }} -&nbsp;</v-label> <v-label class="v-label-timestamp">{{ log.timestamp }} -&nbsp;</v-label>
<v-label class="v-label-topic-message-title">Topic :&nbsp;</v-label> <v-label class="v-label-topic-message-title">Topic :&nbsp;</v-label>

View File

@@ -1,18 +1,18 @@
<template> <template>
<v-container class="v-container-style"> <v-container class="v-container-style">
<v-card tile outlined width="500"> <v-card tile outlined width="600">
<v-card-title class="card__title primary centered-title"> <v-card-title class="card__title primary centered-title">
<v-icon left class="pr-5 pl-2" size="30">mdi-send</v-icon> <v-icon left class="pr-5 pl-2" size="30">mdi-send</v-icon>
Publier un message Publier un message
</v-card-title> </v-card-title>
<div class="input-style"> <div class="input-style">
<v-select <v-text-field
label="Topic" label="Topic"
v-model="selectedTopic" v-model="selectedTopic"
:items="topics" :items="topics"
prepend-icon="mdi-target" prepend-icon="mdi-target"
></v-select> ></v-text-field>
<v-text-field <v-text-field
label="Message" label="Message"
@@ -30,6 +30,42 @@
Déblocage<br>Buzzer Déblocage<br>Buzzer
</v-btn> </v-btn>
<v-btn
rounded
color="RedBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#d42828')"
>
Buzzer
</v-btn>
<v-btn
rounded
color="BlueBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#2867d4')"
>
Buzzer
</v-btn>
<v-btn
rounded
color="YellowBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#D4D100')"
>
Buzzer
</v-btn>
<v-btn
rounded
color="GreenBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#28d42e')"
>
Buzzer
</v-btn>
<v-btn <v-btn
class="v-btn-style-validate" class="v-btn-style-validate"
height="50" height="50"
@@ -53,7 +89,8 @@ const topics = [
'display/control', 'display/control',
'sound/playsound', 'sound/playsound',
'game/score/update', 'game/score/update',
'game/score' 'game/score',
'/display/media'
]; ];
// Methods // Methods
@@ -62,7 +99,18 @@ const publisCustomMessage = () => {
}; };
const publishBuzzerUnblock = () => { const publishBuzzerUnblock = () => {
publishMessage('brainblast/buzzer/unlock', "0"); publishMessage('vulture/buzzer/unlock', "0");
};
const publishBuzzer = (inputColor) => {
publishMessage('vulture/buzzer/pressed/2',JSON.stringify({
buzzer_id: 1,
color: inputColor
}));
// Add a small delay before unlocking to ensure the score update is processed
setTimeout(() => {
publishMessage('/display/control', "pause");
}, 100);
}; };
</script> </script>

View File

@@ -0,0 +1,121 @@
<template>
<v-container v-show="gamehiding === false" class="player_video_div">
<video
ref="videoJsPlayer"
class="video-js player_video"
controls
></video>
</v-container>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import Mysteryland_h264 from '@/quizz/Quizz-1/festival/Mysteryland_h264.mp4';
import { subscribeToTopic } from '@/services/mqttService';
let gamehiding = ref(true);
let player = ref(null);
const videoOptions = {
autoplay: false,
controls: false,
preload: 'auto',
fluid: true,
loop: false,
volume: 0,
sources: [{ src: Mysteryland_h264, type: 'video/mp4' }],
};
const handleMessage = (topic, message) => {
console.log(topic, message)
if (topic === "/display/media") {
switch (message) {
case "BOX_BEAT.mp4":
changeVideoSource("BOX_BEAT.mp4")
break;
case "DARK_VALLEY.mp4":
changeVideoSource("DARK_VALLEY.mp4")
break;
}
}
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
console.log("▶️ Lecture de la vidéo !");
player.value.play().catch((error) => {
console.error("Erreur de lecture :", error);
});
break;
case "pause":
gamehiding.value = true;
console.log("⏸️ Pause de la vidéo !");
player.value.pause();
break;
case "hide":
console.log("🛑 Cacher la vidéo (implémentation à venir)");
break;
}
}
};
const changeVideoSource = (relativePath) => {
try {
const fullPath = new URL(`../quizz/Quizz-1/festival/${relativePath}`, import.meta.url).href;
if (player.value) {
if (relativePath.includes("mp4")){
player.value.src({ src: fullPath, type: 'video/mp4' });
}
if (relativePath.includes(".jpg")){
player.value.src({ src: fullPath, type: 'image/jpeg' });
}
player.value.load();
player.value.play().catch((err) => console.error('Erreur lecture :', err));
}
} catch (error) {
console.error('❌ Erreur lors du chargement de la vidéo :', error);
}
};
// --- Lifecycle
onMounted(() => {
player.value = videojs(
document.querySelector('.video-js'),
videoOptions,
() => {
console.log('🎥 Video player ready');
}
);
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
});
});
onBeforeUnmount(() => {
if (player) {
player.dispose();
}
});
</script>
<style>
.player_video_div {
margin-top: 40px;
width: calc(100vw - 20%);
height: calc(100vh - 20%);
border-radius: 20px !important;
}
.player_video {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
border-radius: 25px !important;
}
.vjs-tech{
border-radius: 25px;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<v-app-bar :elevation="5" height="50">
<RouterMenu />
<v-app-bar-title v-if="$route.name !== 'Accueil'">Vulture</v-app-bar-title>
</v-app-bar>
</template>
<script setup>
import RouterMenu from '@/components/RouterMenu.vue'
</script>

View File

@@ -1,16 +1,15 @@
// Fichier vide, regarde config.js.example pour personaliser ce fichier.
// Note de dev : Normalement ce fichier ne devrait plus avoir de
// modifications
// config.js // config.js
export default { // Reads configuration from window.APP_CONFIG (loaded via public/config.js)
mqttBrokerUrl: 'ws://192.168.1.30:9001', // This allows runtime configuration changes without rebuilding the app.
// Buzzer const defaults = {
mqttBrokerUrl: 'ws://192.168.73.252:9001',
redBuzzerIP: '192.168.73.40', redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41', blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42', orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43' greenBuzzerIP: '192.168.73.43'
// Light
}; };
const config = window.APP_CONFIG || defaults;
export default config;

View File

@@ -3,7 +3,7 @@ import HomeView from '../views/HomeView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ { routes: [{
path: '/', path: '/',
name: 'Accueil', name: 'Accueil',
component: HomeView component: HomeView
@@ -18,6 +18,11 @@ const router = createRouter({
name: 'Game Display (Projection)', name: 'Game Display (Projection)',
component: () => import('@/views/GameDisplay.vue') component: () => import('@/views/GameDisplay.vue')
}, },
{
path: '/score/display',
name: 'Score Display (Projection)',
component: () => import('@/views/ScoreDisplay.vue')
},
{ {
path: '/mqtt-debugger', path: '/mqtt-debugger',
name: 'Debugger MQTT', name: 'Debugger MQTT',
@@ -26,7 +31,8 @@ const router = createRouter({
{ {
path: '/settings', path: '/settings',
name: 'Paramètres', name: 'Paramètres',
component: () => import('@/views/SettingsView.vue') } component: () => import('@/views/SettingsView.vue')
}
] ]
}) })

View File

@@ -21,6 +21,7 @@ const CustomThemeDark = {
error: '#e91e1e', error: '#e91e1e',
warning: '#FFC107', warning: '#FFC107',
info: '#607D8B', info: '#607D8B',
inactiveButton: '#707070ff',
success: '#15B01B', success: '#15B01B',
BlueBuzzer: '#2867d4', BlueBuzzer: '#2867d4',
YellowBuzzer: '#D4D100', YellowBuzzer: '#D4D100',
@@ -39,6 +40,7 @@ const CustomThemeLight = {
error: '#e91e1e', error: '#e91e1e',
warning: '#FFC107', warning: '#FFC107',
info: '#607D8B', info: '#607D8B',
inactiveButton: '#707070ff',
success: '#4CAF50', success: '#4CAF50',
BlueBuzzer: '#2867d4', BlueBuzzer: '#2867d4',
YellowBuzzer: '#D4D100', YellowBuzzer: '#D4D100',
@@ -54,6 +56,7 @@ export default createVuetify({
defaultTheme: 'CustomThemeDark', defaultTheme: 'CustomThemeDark',
themes: { themes: {
CustomThemeDark, CustomThemeDark,
CustomThemeLight, }, CustomThemeLight,
},
}, },
}) })

166
VApp/src/store/quizStore.js Normal file
View File

@@ -0,0 +1,166 @@
import { reactive, computed } from 'vue';
import mqtt from 'mqtt';
import config from '@/config.js';
import sessionConfig from '@/quizz/vulture-session-2026-01/session-configuration.json';
// Reactive state
const state = reactive({
currentQuestionIndex: 0,
questions: [],
isMediaHidden: true,
packTitle: '',
timer: 0 // Timer in seconds
});
// MQTT Client
let client = null;
let timerInterval = null;
let initialized = false;
// Initialize Store
function init() {
if (initialized) {
console.log('QuizStore: Already initialized');
return;
}
initialized = true;
// Load local config immediately
state.questions = sessionConfig.Questions || [];
state.packTitle = sessionConfig.PackTitle || '';
// Connect MQTT
client = mqtt.connect(config.mqttBrokerUrl);
client.on('connect', () => {
console.log('QuizStore: MQTT Connected');
client.subscribe('game/quiz/control');
client.subscribe('/display/control');
});
client.on('message', (topic, message) => {
const msgStr = message.toString();
console.log('QuizStore: MQTT Message Received', topic, msgStr);
if (topic === 'game/quiz/control') {
try {
const payload = JSON.parse(msgStr);
handleRemoteCommand(payload);
} catch (e) {
console.error('QuizStore: JSON Parse Error', e);
}
} else if (topic === '/display/control') {
// Handle raw string commands from MqttButtons
if (msgStr === 'next') {
_nextQuestion(true);
} else if (msgStr === 'previous') {
_prevQuestion(true);
} else if (msgStr === 'play') {
// Start timer for picture questions
const currentQ = state.questions[state.currentQuestionIndex];
if (currentQ?.Type === 'picture') {
const playTime = currentQ.Settings?.PlayTime;
if (playTime && playTime > 0) {
console.log('QuizStore: Starting timer for picture', playTime);
actions.startTimer(playTime);
}
}
} else if (msgStr === 'pause') {
stopTimer();
}
}
});
}
function handleRemoteCommand(cmd) {
if (cmd.action === 'next') {
_nextQuestion(false);
} else if (cmd.action === 'prev') {
_prevQuestion(false);
} else if (cmd.action === 'setIndex') {
state.currentQuestionIndex = cmd.index;
}
}
// Internal actions (boolean publish determines if we send MQTT)
function _nextQuestion(publish = true) {
if (state.currentQuestionIndex < state.questions.length - 1) {
state.currentQuestionIndex++;
if (publish && client) {
client.publish('game/quiz/control', JSON.stringify({ action: 'setIndex', index: state.currentQuestionIndex }));
}
}
}
function _prevQuestion(publish = true) {
if (state.currentQuestionIndex > 0) {
state.currentQuestionIndex--;
if (publish && client) {
client.publish('game/quiz/control', JSON.stringify({ action: 'setIndex', index: state.currentQuestionIndex }));
}
}
}
// Public Actions
const actions = {
init,
nextQuestion: () => _nextQuestion(true),
prevQuestion: () => _prevQuestion(true),
setQuestion: (index) => {
if (index >= 0 && index < state.questions.length) {
state.currentQuestionIndex = index;
if (client) {
client.publish('game/quiz/control', JSON.stringify({ action: 'setIndex', index: index }));
}
}
},
startTimer: (seconds) => {
stopTimer();
state.timer = seconds;
publishTimer();
timerInterval = setInterval(() => {
if (state.timer > 0) {
state.timer--;
publishTimer();
} else {
stopTimer();
// Auto-hide by publishing pause
if (client) {
client.publish('/display/control', 'pause');
}
}
}, 1000);
},
stopTimer
};
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
state.timer = 0;
publishTimer();
}
function publishTimer() {
if (client) {
client.publish('game/timer', JSON.stringify({ time: state.timer }));
}
}
// Getters
const getters = {
currentQuestion: computed(() => state.questions[state.currentQuestionIndex]),
isFirstQuestion: computed(() => state.currentQuestionIndex === 0),
isLastQuestion: computed(() => state.currentQuestionIndex === state.questions.length - 1),
totalQuestions: computed(() => state.questions.length),
packTitle: computed(() => state.packTitle),
currentQuestionIndex: computed(() => state.currentQuestionIndex),
timer: computed(() => state.timer)
};
export default {
state,
actions,
getters
};

View File

@@ -5,18 +5,18 @@ export default {
RedTotalScore: 11, RedTotalScore: 11,
BlueTotalScore: 22, BlueTotalScore: 22,
GreenTotalScore: 33, GreenTotalScore: 33,
OrangeTotalScore: 44, YellowTotalScore: 44,
// Score de la manche courante // Score de la manche courante
RedRoundScore: 1, RedRoundScore: 1,
BlueRoundScore: 2, BlueRoundScore: 2,
OrangeRoundScore: 3, YellowRoundScore: 3,
GreenRoundScore: 4, GreenRoundScore: 4,
//Etat des buzzer //Etat des buzzer
BuzzerRed: false, BuzzerRed: false,
BuzzerBlue: false, BuzzerBlue: false,
BuzzerOrange: false, BuzzerYellow: false,
BuzzerGreen: false, BuzzerGreen: false,
// Ajoutez d'autres variables globales ici // Ajoutez d'autres variables globales ici
@@ -24,4 +24,4 @@ export default {
// Variables localStorage // Variables localStorage
export const localStorageVars = { // Exemple de variable localStorage RedScorelocal: localStorage.getItem('RedScore') || '', BlueScorelocal: localStorage.getItem('BlueScore') || '', OrangeScorelocal: localStorage.getItem('OrangeScore') || '', GreenScorelocal: localStorage.getItem('GreenScore') || '', export const localStorageVars = { // Exemple de variable localStorage RedScorelocal: localStorage.getItem('RedScore') || '', BlueScorelocal: localStorage.getItem('BlueScore') || '', OrangeScorelocal: localStorage.getItem('OrangeScore') || '', GreenScorelocal: localStorage.getItem('GreenScore') || '',
}; };

View File

@@ -5,71 +5,78 @@
<card-control /> <card-control />
</v-col> </v-col>
<v-col class="pl-3"> <v-col class="pl-3">
<card-soundboard /> <CardButtonScore />
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
<v-row no-gutters class="pr-4 pl-4"> <v-row no-gutters class="pr-4 pl-4">
<v-row no-gutters> <v-row no-gutters>
<v-col class="align-start"> <v-col class="align-start">
<CardButtonScore /> <card-solution />
</v-col> </v-col>
<v-col class="pl-3"> <v-col class="pl-3">
<card-solution />
</v-col> </v-col>
</v-row> </v-row>
</v-row> </v-row>
<BuzzerValidationDialog />
</template> </template>
<script setup> <script setup>
import CardSolution from '@/components/CardSolution.vue' import CardSolution from '@/components/CardSolution.vue'
import CardControl from '@/components/CardControl.vue' import CardControl from '@/components/CardControl.vue'
import CardSoundboard from '@/components/CardSoundboard.vue';
import CardButtonScore from '@/components/CardButtonScore.vue' import CardButtonScore from '@/components/CardButtonScore.vue'
import BuzzerValidationDialog from '@/components/BuzzerValidationDialog.vue';
import { onMounted } from 'vue';
import quizStore from '@/store/quizStore';
onMounted(() => {
quizStore.actions.init();
});
</script> </script>
<style> <style>
@media (min-width: 1024px) { @media (min-width: 1024px) {
.card__title.primary { .card__title.primary {
background-color: #d42828; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-primary)); /* Changez la couleur en fonction de votre thème */
} }
.card__title.feedback { .card__title.feedback {
background-color: #2E7D32; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-success)); /* Changez la couleur en fonction de votre thème */
} }
.btn{ .btn{
border-radius:20px!important; border-radius:20px!important;
} }
.btn.red { .btn.red {
background-color: #d42828; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-RedBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.btn.blue { .btn.blue {
background-color: #2867d4; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-BlueBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.btn.yellow { .btn.yellow {
background-color: #d4d100; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-YellowBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.btn.green { .btn.green {
background-color: #28d42e; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-GreenBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.scorediv-style-red { .scorediv-style-red {
background-color: #d42828 !important; background-color: rgb(var(--v-theme-RedBuzzer)) !important;
padding: 15px; padding: 15px;
border-top-left-radius: 10%; border-top-left-radius: 10%;
} }
.scorediv-style-yellow { .scorediv-style-yellow {
background-color: #d4d100!important; background-color: rgb(var(--v-theme-YellowBuzzer)) !important;
padding: 15px; padding: 15px;
border-bottom-left-radius: 10%; border-bottom-left-radius: 10%;
} }
.scorediv-style-blue { .scorediv-style-blue {
background-color: #2867d4 !important; background-color: rgb(var(--v-theme-BlueBuzzer)) !important;
padding: 15px; padding: 15px;
border-top-right-radius: 10%; border-top-right-radius: 10%;
} }
.scorediv-style-green { .scorediv-style-green {
background-color: #28d42e !important; background-color: rgb(var(--v-theme-GreenBuzzer)) !important;
padding: 15px; padding: 15px;
border-bottom-right-radius: 10%; border-bottom-right-radius: 10%;
} }

View File

@@ -2,113 +2,133 @@
<div class="main_div"> <div class="main_div">
<div> <div>
<v-container class="score_div_main"> <v-container class="score_div_main">
<v-container class="score_div color-blue"></v-container> <v-container class="score_div color-blue">
<v-container class="score_div color-red"></v-container> <div class="d-flex flex-column align-center">
<v-container class="score_div color-white d-flex align-center justify-center"> <Transition name="score-fade" mode="out-in">
<span class="v-label-time">00:00</span> <span :key="scores.BlueRoundScore" class="v-label-round-score">Manche : {{ scores.BlueRoundScore }}</span>
</Transition>
<Transition name="score-fade" mode="out-in">
<span :key="scores.BlueTotalScore" class="v-label-score">{{ scores.BlueTotalScore }}</span>
</Transition>
</div>
</v-container>
<v-container class="score_div color-red">
<div class="d-flex flex-column align-center">
<Transition name="score-fade" mode="out-in">
<span :key="scores.RedRoundScore" class="v-label-round-score">Manche : {{ scores.RedRoundScore }}</span>
</Transition>
<Transition name="score-fade" mode="out-in">
<span :key="scores.RedTotalScore" class="v-label-score">{{ scores.RedTotalScore }}</span>
</Transition>
</div>
</v-container>
<v-container class="score_div color-white d-flex align-center justify-center">
<span class="v-label-time">{{ timerDisplay }}</span>
</v-container>
<v-container class="score_div color-green">
<div class="d-flex flex-column align-center">
<Transition name="score-fade" mode="out-in">
<span :key="scores.GreenRoundScore" class="v-label-round-score">Manche : {{ scores.GreenRoundScore }}</span>
</Transition>
<Transition name="score-fade" mode="out-in">
<span :key="scores.GreenTotalScore" class="v-label-score">{{ scores.GreenTotalScore }}</span>
</Transition>
</div>
</v-container>
<v-container class="score_div color-yellow">
<div class="d-flex flex-column align-center">
<Transition name="score-fade" mode="out-in">
<span :key="scores.YellowRoundScore" class="v-label-round-score">Manche : {{ scores.YellowRoundScore }}</span>
</Transition>
<Transition name="score-fade" mode="out-in">
<span :key="scores.YellowTotalScore" class="v-label-score">{{ scores.YellowTotalScore }}</span>
</Transition>
</div>
</v-container> </v-container>
<v-container class="score_div color-green"></v-container>
<v-container class="score_div color-yellow"></v-container>
</v-container> </v-container>
</div> </div>
<div> <div>
<v-container v-show="gamehiding === true" class="v-container-game-hided"> <HidingOverlay/>
<v-img src="@\assets\v-hide.png" class="v-img-hidding"></v-img> <GameMedia/>
</v-container>
<v-container v-show="gamehiding === false" class="player_video_div">
<video
ref="videoJsPlayer"
class="video-js player_video"
controls
></video>
</v-container>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'; //import VideoPlayer from "@/components/VideoPlayer.vue"
import videojs from 'video.js'; import GameMedia from "@/components/GameMedia.vue"
import 'video.js/dist/video-js.css'; import HidingOverlay from "@/components/HidingOverlay.vue"
import Mysteryland_h264 from '../quizz/Quizz-1/festival/Mysteryland_h264.mp4'; import { onMounted, reactive } from 'vue';
import { subscribeToTopic } from '@/services/mqttService'; import mqtt from 'mqtt'
import config from '@/config.js'
import quizStore from '@/store/quizStore';
// --- Déclarations const mqttBrokerUrl = config.mqttBrokerUrl
const player = ref(null); const client = mqtt.connect(mqttBrokerUrl)
let gamehiding = ref(true)
const videoOptions = { const scores = reactive({
autoplay: false, RedTotalScore: 0,
controls: false, BlueTotalScore: 0,
preload: 'auto', YellowTotalScore: 0,
fluid: true, GreenTotalScore: 0,
loop: true, RedRoundScore: 0,
volume: 0, BlueRoundScore: 0,
sources: [{ src: Mysteryland_h264, type: 'video/mp4' }], YellowRoundScore: 0,
}; GreenRoundScore: 0,
// --- Fonctions
const playVideo = () => {
if (player.value) {
console.log("▶️ Lecture de la vidéo !");
player.value.play().catch((error) => {
console.error("Erreur de lecture :", error);
});
} else {
console.warn("⚠️ Player non encore initialisé !");
}
};
const pauseVideo = () => {
if (player.value) {
console.log("⏸️ Pause de la vidéo !");
player.value.pause();
} else {
console.warn("⚠️ Player non encore initialisé !");
}
};
const handleMessage = (topic, message) => {
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
playVideo();
break;
case "pause":
gamehiding.value = true;
pauseVideo();
break;
case "hide":
console.log("🛑 Cacher la vidéo (implémentation à venir)");
break;
default:
console.warn("Commande non reconnue :", message);
}
}
};
// --- Lifecycle
onMounted(() => {
player.value = videojs(
document.querySelector('.video-js'),
videoOptions,
() => {
console.log('🎥 Video player ready');
}
);
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
}); });
});
onBeforeUnmount(() => { import { ref } from 'vue';
if (player.value) { const timerDisplay = ref('00:00');
player.value.dispose();
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
} }
});
function handleMessage(topic, message) {
let parsedMessage;
try {
parsedMessage = JSON.parse(message);
} catch (e) {
console.error("Erreur d'analyse JSON:", e);
return;
}
if (topic === 'game/score' && parsedMessage.TEAM) {
scores.RedTotalScore = parsedMessage.TEAM.Red.TotalScore
scores.BlueTotalScore = parsedMessage.TEAM.Blue.TotalScore
scores.YellowTotalScore = parsedMessage.TEAM.Yellow.TotalScore
scores.GreenTotalScore = parsedMessage.TEAM.Green.TotalScore
scores.RedRoundScore = parsedMessage.TEAM.Red.RoundScore
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
}
if (topic === 'game/timer' && parsedMessage.time !== undefined) {
timerDisplay.value = formatTime(parsedMessage.time);
}
}
function subscribeToTopic(topic, callback) {
client.subscribe(topic)
client.on('message', (receivedTopic, message) => { callback(receivedTopic.toString(), message.toString())
})
}
onMounted(() => {
quizStore.actions.init();
subscribeToTopic('game/score', (topic, message) => {
handleMessage(topic, message);
});
subscribeToTopic('game/timer', (topic, message) => {
handleMessage(topic, message);
});
});
</script> </script>
<style scoped> <style scoped>
@@ -122,67 +142,75 @@ onBeforeUnmount(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 5px; gap: 5px;
background-color: rgb(80, 80, 80); background-color: rgb(40, 40, 40);
padding: 25px 30px; padding: 25px 30px;
border-radius: 0px 0px 30px 30px; border-radius: 0px 0px 30px 30px;
box-shadow: 0px 1px 5px rgb(255, 0, 0); box-shadow: 0px 3px 45px rgb(45, 115, 166);
} }
.score_div { .score_div {
height: 100px; height: 100px;
width: 170px; width: 170px;
text-align: center; text-align: center;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: transform 0.2s;
} }
.color-blue { .color-blue {
background-color: rgb(var(--v-theme-BlueBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-BlueBuzzer)), #1a3a5a);
border-radius: 40px 5px 40px 5px; border-radius: 40px 5px 40px 5px;
} }
.color-red { .color-red {
background-color: rgb(var(--v-theme-RedBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-RedBuzzer)), #5a1a1a);
border-radius: 40px 5px 40px 5px; border-radius: 40px 5px 40px 5px;
} }
.color-green { .color-green {
background-color: rgb(var(--v-theme-GreenBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-GreenBuzzer)), #1a5a2a);
border-radius: 5px 40px 5px 40px; border-radius: 5px 40px 5px 40px;
} }
.color-yellow { .color-yellow {
background-color: rgb(var(--v-theme-YellowBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-YellowBuzzer)), #5a5a1a);
border-radius: 5px 40px 5px 40px; border-radius: 5px 40px 5px 40px;
} }
.color-white { .color-white {
background-color: white; background-color: #1a1a1a;
border: 3px solid #333;
border-radius: 40px; border-radius: 40px;
box-shadow: 0 0 30px rgba(0,0,0,0.6);
} }
.v-label-time { .v-label-time {
padding-top: 5px; padding-top: 5px;
color: black; color: white;
font-size: 49px; font-size: 49px;
font-family: 'Bahnschrift'; font-family: 'Bahnschrift';
text-shadow: 0 0 15px rgba(255, 255, 255, 0.2);
}
.v-label-score {
color: white;
font-size: 40px;
font-family: 'Bahnschrift';
font-weight: bold;
line-height: 1;
text-shadow: 4px 4px 8px rgba(0,0,0,0.4);
}
.v-label-round-score {
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
font-family: 'Bahnschrift';
font-weight: 500;
margin-bottom: 2px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
} }
.player_video_div {
margin-top: 40px;
width: calc(100vw - 20%);
height: calc(100vh - 20%);
border-radius: 20px !important;
/* Transition styles */
.score-fade-enter-active,
.score-fade-leave-active {
transition: opacity 0.3s ease;
} }
.player_video {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
border-radius: 25px !important;
} .score-fade-enter-from,
.vjs-tech{ .score-fade-leave-to {
border-radius: 25px; opacity: 0;
}
.v-container-game-hided{
margin-top: 40px;
width: calc(100vw - 20%) !important;
height: calc(100vh - 20%) !important;
border-radius: 25px;
}
.v-img-hidding{
border-radius: 25px;
} }
</style> </style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="score-grid">
<div class="score-cell cell-blue color-blue">
<div class="score-content">
<div class="score-info info-left">
<div class="team-name">Bleue</div>
<div class="sub-score-container sub-left">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.BlueTotalScore }}</span>
</div>
</div>
<div class="score-main">
<div class="team-score main-score">{{ scores.BlueRoundScore }}</div>
</div>
</div>
</div>
<div class="score-cell cell-red color-red">
<div class="score-content">
<div class="score-main">
<div class="team-score main-score">{{ scores.RedRoundScore }}</div>
</div>
<div class="score-info info-right">
<div class="team-name">Rouge</div>
<div class="sub-score-container sub-right">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.RedTotalScore }}</span>
</div>
</div>
</div>
</div>
<div class="score-cell cell-green color-green">
<div class="score-content">
<div class="score-info info-left">
<div class="team-name">Verte</div>
<div class="sub-score-container sub-left">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.GreenTotalScore }}</span>
</div>
</div>
<div class="score-main">
<div class="team-score main-score">{{ scores.GreenRoundScore }}</div>
</div>
</div>
</div>
<div class="score-cell cell-yellow color-yellow">
<div class="score-content">
<div class="score-main">
<div class="team-score main-score">{{ scores.YellowRoundScore }}</div>
</div>
<div class="score-info info-right">
<div class="team-name">Jaune</div>
<div class="sub-score-container sub-right">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.YellowTotalScore }}</span>
</div>
</div>
</div>
</div>
<div class="timer-container">
<div class="timer-display">{{ timerDisplay }}</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import mqtt from 'mqtt'
import config from '@/config.js'
const mqttBrokerUrl = config.mqttBrokerUrl
let client = null
const scores = reactive({
RedTotalScore: 0,
BlueTotalScore: 0,
YellowTotalScore: 0,
GreenTotalScore: 0,
RedRoundScore: 0,
BlueRoundScore: 0,
YellowRoundScore: 0,
GreenRoundScore: 0,
});
const timerDisplay = ref('00:00');
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
function handleMessage(topic, message) {
let parsedMessage;
try {
parsedMessage = JSON.parse(message);
} catch (e) {
console.error("Erreur d'analyse JSON:", e);
return;
}
if (topic === 'game/score' && parsedMessage.TEAM) {
scores.RedTotalScore = parsedMessage.TEAM.Red.TotalScore
scores.BlueTotalScore = parsedMessage.TEAM.Blue.TotalScore
scores.YellowTotalScore = parsedMessage.TEAM.Yellow.TotalScore
scores.GreenTotalScore = parsedMessage.TEAM.Green.TotalScore
scores.RedRoundScore = parsedMessage.TEAM.Red.RoundScore
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
}
if (topic === 'game/timer' && parsedMessage.time !== undefined) {
timerDisplay.value = formatTime(parsedMessage.time);
}
}
function subscribeToTopic(topic, callback) {
if(client) {
client.subscribe(topic)
client.on('message', (receivedTopic, message) => { callback(receivedTopic.toString(), message.toString())
})
}
}
onMounted(() => {
client = mqtt.connect(mqttBrokerUrl)
subscribeToTopic('game/score', (topic, message) => {
handleMessage(topic, message);
});
subscribeToTopic('game/timer', (topic, message) => {
handleMessage(topic, message);
});
});
onUnmounted(() => {
if (client) {
client.end()
}
})
</script>
<style scoped>
.score-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100vw;
height: 100vh;
padding: 40px;
gap: 60px;
position: relative;
font-family: 'Bahnschrift', sans-serif;
}
.score-cell {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: transform 0.2s;
overflow: hidden;
}
.cell-blue {
border-radius: 65px 225px 75px 225px !important;
}
.cell-red {
border-radius: 225px 65px 225px 75px !important;
}
.cell-green {
border-radius: 225px 65px 225px 75px !important;
}
.cell-yellow {
border-radius: 75px 225px 65px 225px !important;
}
.score-content {
display: flex;
flex-direction: row; /* Horizontal layout */
align-items: center;
justify-content: space-around; /* Spread out */
width: 100%;
padding: 0 40px;
}
.score-main {
flex: 2; /* Takes more space */
display: flex;
justify-content: center;
align-items: center;
}
.score-info {
flex: 1; /* Takes less space */
display: flex;
flex-direction: column;
justify-content: center;
}
.info-right {
align-items: flex-end;
text-align: right;
}
.info-left {
align-items: flex-start;
text-align: left;
}
.team-name {
font-size: 2.2rem;
font-weight: 900;
letter-spacing: 2px;
opacity: 0.9;
color: rgba(255, 255, 255, 0.95);
text-transform: uppercase;
margin-bottom: 15px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.team-score {
font-weight: bold;
color: white;
line-height: 1;
text-shadow: 4px 4px 8px rgba(0,0,0,0.4);
}
.main-score {
font-size: 10rem; /* Even larger */
margin: 0;
}
.sub-score-container {
background-color: rgba(0, 0, 0, 0.2);
padding: 10px 20px;
border-radius: 20px;
display: flex;
flex-direction: column;
min-width: 120px;
}
.sub-right {
align-items: flex-end;
}
.sub-left {
align-items: flex-start;
}
.sub-label {
font-size: 1rem;
text-transform: uppercase;
opacity: 0.8;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 2px;
font-weight: 600;
}
.sub-score {
font-size: 2.5rem;
}
.timer-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
background-color: #1a1a1a;
padding: 20px 40px;
border-radius: 80px;
border: 6px solid #333;
box-shadow: 0 0 50px rgba(0,0,0,0.8);
}
.timer-display {
font-size: 8rem;
font-weight: bold;
color: white;
font-family: monospace;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.2);
}
.color-blue {
background: linear-gradient(135deg, rgb(var(--v-theme-BlueBuzzer)), #1a3a5a);
}
.color-red {
background: linear-gradient(135deg, rgb(var(--v-theme-RedBuzzer)), #5a1a1a);
}
.color-green {
background: linear-gradient(135deg, rgb(var(--v-theme-GreenBuzzer)), #1a5a2a);
}
.color-yellow {
background: linear-gradient(135deg, rgb(var(--v-theme-YellowBuzzer)), #5a5a1a);
}
</style>

View File

@@ -0,0 +1,60 @@
# NanoMQ Configuration 0.18.0
# #============================================================
# # NanoMQ Broker
# #============================================================
mqtt {
property_size = 32
max_packet_size = 260MB
max_mqueue_len = 2048
retry_interval = 10s
keepalive_multiplier = 1.25
# Three of below, unsupported now
max_inflight_window = 2048
max_awaiting_rel = 10s
await_rel_timeout = 10s
}
listeners.tcp {
bind = "0.0.0.0:1883"
}
listeners.ws {
bind = "0.0.0.0:9001"
}
http_server {
port = 8081
limit_conn = 2
username = admin
password = public
auth_type = basic
jwt {
public.keyfile = "/etc/certs/jwt/jwtRS256.key.pub"
}
}
log {
# to = [file, console]
to = [console]
level = warn
dir = "/tmp"
file = "nanomq.log"
rotation {
size = 10MB
count = 5
}
}
auth {
allow_anonymous = true
no_match = allow
deny_action = ignore
cache = {
max_size = 32
ttl = 1m
}
}

101
VContainers/README.md Normal file
View File

@@ -0,0 +1,101 @@
# VContainer - Vulture build script
Construction et lancements des containers.
Toutes les commandes sont à taper depuis la racine du dépôt.
## Build
```bash
./VContainers/build.sh
```
Ou manuellement :
```bash
podman build . -f ./VContainers/VNode/Containerfile -t vnode
podman build . -f ./VContainers/VApp/Containerfile -t vapp
```
## Run
### Mode Manuel avec Scripts
**Développement (localhost):**
```bash
./VContainers/run_dev.sh
```
**Production (IP 192.168.73.252):**
```bash
./VContainers/run_prod.sh
```
Les containers sont lancés sur le réseau bridge `vulture-net` :
- **nanomq** : Broker MQTT (ports 1883, 9001, 8081, 8083, 8883)
- **vnode** : Services Node.js backend
- **vapp** : Frontend Vue.js (port 8080)
## Stop
```bash
./VContainers/stop.sh
```
Ou manuellement :
```bash
podman stop vapp vnode nanomq
podman network rm vulture-net
```
## Lancement automatique avec Quadlet
Copier les fichiers du répertoire `quadlet` vers `~/.config/containers/systemd/`
```bash
cp ./VContainers/quadlet/*.network ~/.config/containers/systemd/
cp ./VContainers/quadlet/*.container ~/.config/containers/systemd/
```
**Pour l'environnement de développement :**
```bash
systemctl --user daemon-reload
systemctl --user enable --now nanomq.service
systemctl --user enable --now vnode.service
systemctl --user enable --now vapp_dev.service
```
**Pour l'environnement de production :**
```bash
systemctl --user daemon-reload
systemctl --user enable --now nanomq.service
systemctl --user enable --now vnode.service
systemctl --user enable --now vapp_prod.service
```
**Vérifier le statut :**
```bash
systemctl --user status nanomq.service vnode.service vapp_dev.service
```
**Arrêter les services :**
```bash
systemctl --user stop vapp_dev.service vnode.service nanomq.service
systemctl --user disable vapp_dev.service vnode.service nanomq.service
```
## Configuration
Les fichiers de configuration se trouvent dans `VContainers/VApp/config/` :
- `config_dev.js` : Configuration développement (MQTT sur localhost)
- `config_prod.js` : Configuration production (MQTT sur 192.168.73.252)
Vous pouvez modifier ces fichiers selon vos besoins. En mode manuel, redémarrez les containers. Avec Quadlet, redémarrez le service correspondant :
```bash
systemctl --user restart vapp_dev.service
```
## Tip
Pour permettre à Podman d'utiliser les ports privilégiés (<1024) :
```bash
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
```

View File

@@ -0,0 +1,16 @@
# Development Container for VApp
FROM docker.io/node:lts-alpine
WORKDIR /app
# Copy VApp source code
COPY VApp ./
# Install dependencies
RUN npm install
# Expose Vite default port
EXPOSE 5173
# Start in development mode with host exposure
CMD ["npm", "run", "dev", "--", "--host"]

View File

@@ -0,0 +1,11 @@
window.APP_CONFIG = {
// URL du broker MQTT (WebSockets)
// Configuration DEV : localhost
mqttBrokerUrl: 'ws://localhost:9001',
// IPs des buzzers
redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43'
};

View File

@@ -0,0 +1,11 @@
window.APP_CONFIG = {
// URL du broker MQTT (WebSockets)
// Configuration PROD : IP du serveur
mqttBrokerUrl: 'ws://192.168.73.252:9001',
// IPs des buzzers
redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43'
};

View File

@@ -0,0 +1,24 @@
server {
listen 80;
root /usr/share/nginx/html; # Chemin où les fichiers statiques sont copiés
index index.html index.htm; # Fichier par défaut à servir
location / {
try_files $uri $uri/ /index.html; # Pour les applications SPA (Single Page Applications)
# cela redirige toutes les requêtes non trouvées vers index.html
}
# Cache Control pour les fichiers statiques (optionnel mais bonne pratique)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Gzip compression (optionnel, améliore la performance)
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

View File

@@ -0,0 +1,12 @@
FROM docker.io/keymetrics/pm2:latest-alpine
## Bundle APP files
COPY VNode src
#COPY package.json .
COPY VContainers/VNode/pm2.json .
#
## Install app dependencies
RUN cd /src && npm install
CMD [ "pm2-runtime", "start", "pm2.json" ]
#CMD [ "sh"]

View File

@@ -0,0 +1,44 @@
[
{
"name": "buzzer-manager",
"script": "services/buzzer/buzzer-manager.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
}
},
{
"name": "buzzer-watcher",
"script": "services/buzzer/buzzer-watcher.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
} },
{
"name": "quizz-collector",
"script": "services/game/quizz-collector.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
} },
{
"name": "score-manager",
"script": "services/game/score-manager.js",
"cwd": "src",
"env": {
"NODE_ENV": "production"
},
"watch": false
},
{
"name": "light-manager",
"script": "services/light/light-manager.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
} }
]

13
VContainers/build.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
# Move to repository root
cd "$(dirname "$0")/.."
echo "Building VNode..."
podman build . -f ./VContainers/VNode/Containerfile -t vnode
echo "Building VApp..."
podman build . -f ./VContainers/VApp/Containerfile -t vapp
echo "Build complete."

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Broker MQTT NanoMQ
Wants=network-online.target
After=network-online.target
[Container]
Image=docker.io/emqx/nanomq:latest
ContainerName=nanomq
Network=vulture-net.network
PublishPort=1883:1883
PublishPort=9001:9001
PublishPort=8081:8081
PublishPort=8083:8083
PublishPort=8883:8883
Volume=%h/Src/Fablab/Vulture/VContainers/MQTT/config/nanomq.conf:/etc/nanomq.conf:Z
Exec=--conf /etc/nanomq.conf
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Application Node.js VApp
Requires=vulture.pod
After=vulture.pod
[Container]
Image=localhost/vapp:latest
ContainerName=vapp
Pod=vulture
[Install]
WantedBy=vulture.pod

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Application Vue.js VApp (DEV)
Wants=network-online.target
After=network-online.target
Requires=nanomq.service
After=nanomq.service
[Container]
Image=localhost/vapp:latest
ContainerName=vapp
Network=vulture-net.network
PublishPort=8080:80
Volume=%h/Src/Fablab/Vulture/VContainers/VApp/config/config_dev.js:/usr/share/nginx/html/config.js:Z
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Application Vue.js VApp (PROD)
Wants=network-online.target
After=network-online.target
Requires=nanomq.service
After=nanomq.service
[Container]
Image=localhost/vapp:latest
ContainerName=vapp
Network=vulture-net.network
PublishPort=8080:80
Volume=%h/Src/Fablab/Vulture/VContainers/VApp/config/config_prod.js:/usr/share/nginx/html/config.js:Z
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Application Node.js VNode
Wants=network-online.target
After=network-online.target
Requires=nanomq.service
After=nanomq.service
[Container]
Image=localhost/vnode:latest
ContainerName=vnode
Network=vulture-net.network
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Reseau Bridge pour Vulture
[Network]
NetworkName=vulture-net
Driver=bridge

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Pod Vulture pour le Broker MQTT et les Applications Node
Wants=network-online.target
After=network-online.target
[Pod]
# Mappings de ports : Host:Container (ces ports sont partagés par tous les conteneurs)
PublishPort=8080:80
PublishPort=1883:1883
PublishPort=8083:8083
PublishPort=8883:8883
[Install]
WantedBy=default.target

33
VContainers/run_dev.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
# Move to repository root
cd "$(dirname "$0")/.."
NETWORK_NAME="vulture-net"
echo "Creating network $NETWORK_NAME..."
if podman network exists $NETWORK_NAME; then
echo "Network $NETWORK_NAME already exists."
else
podman network create $NETWORK_NAME
fi
echo "Starting NanoMQ..."
# NanoMQ needs to expose ports for external access (e.g. VApp frontend) and be on the network for VNode
podman run -dt --rm --network $NETWORK_NAME --name nanomq \
-p 1883:1883 -p 9001:9001 -p 8081:8081 -p 8083:8083 -p 8883:8883 \
-v ./VContainers/MQTT/config/nanomq.conf:/etc/nanomq.conf:Z \
docker.io/emqx/nanomq:latest --conf /etc/nanomq.conf
echo "Starting VNode..."
# VNode connects to nanomq via the network, no ports needed on host unless for debugging
podman run -dt --rm --network $NETWORK_NAME --name vnode vnode:latest
echo "Starting VApp (DEV CONFIG)..."
# VApp (nginx) needs port 5173 exposed
podman run -dt --rm --network $NETWORK_NAME --name vapp -p 5173:5173 \
-v ./VContainers/VApp/config/config_dev.js:/usr/share/nginx/html/config.js:Z \
vapp:latest
echo "All containers started on network $NETWORK_NAME with DEV configuration."

33
VContainers/run_prod.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
# Move to repository root
cd "$(dirname "$0")/.."
NETWORK_NAME="vulture-net"
echo "Creating network $NETWORK_NAME..."
if podman network exists $NETWORK_NAME; then
echo "Network $NETWORK_NAME already exists."
else
podman network create $NETWORK_NAME
fi
echo "Starting NanoMQ..."
# NanoMQ needs to expose ports for external access (e.g. VApp frontend) and be on the network for VNode
podman run -dt --rm --network $NETWORK_NAME --name nanomq \
-p 1883:1883 -p 9001:9001 -p 8081:8081 -p 8083:8083 -p 8883:8883 \
-v ./VContainers/MQTT/config/nanomq.conf:/etc/nanomq.conf:Z \
docker.io/emqx/nanomq:latest --conf /etc/nanomq.conf
echo "Starting VNode..."
# VNode connects to nanomq via the network, no ports needed on host unless for debugging
podman run -dt --rm --network $NETWORK_NAME --name vnode vnode:latest
echo "Starting VApp (PROD CONFIG)..."
# VApp (nginx) needs port 5173 exposed
podman run -dt --rm --network $NETWORK_NAME --name vapp -p 5173:5173 \
-v ./VContainers/VApp/config/config_prod.js:/usr/share/nginx/html/config.js:Z \
vapp:latest
echo "All containers started on network $NETWORK_NAME with PROD configuration."

11
VContainers/stop.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
echo "Stopping containers..."
podman stop vapp || echo "vapp not running"
podman stop vnode || echo "vnode not running"
podman stop nanomq || echo "nanomq not running"
echo "Removing network..."
podman network rm vulture-net || echo "Network vulture-net not found"
echo "Cleanup complete."

View File

@@ -1,8 +1,14 @@
// Import necessary modules // Import necessary modules
const mqtt = require('mqtt'); const mqtt = require('mqtt');
const fs = require('fs');
const path = require('path');
// Load configuration
const configPath = path.join(__dirname, '../config/configuration.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// MQTT broker configuration // MQTT broker configuration
const brokerUrl = 'mqtt://localhost'; // Broker URL (change if needed) const brokerUrl = config.mqttHost;
const clientId = 'buzzer_manager'; const clientId = 'buzzer_manager';
const options = { const options = {
clientId, clientId,
@@ -64,8 +70,7 @@ function sendTiltStatus(action, buzzerId) {
client.publish('vulture/buzzer/status', JSON.stringify({ client.publish('vulture/buzzer/status', JSON.stringify({
status: "tilt_update", status: "tilt_update",
tilt_buzzers: tiltList, tilt_buzzers: tiltList,
message: `Buzzer ID ${buzzerId} ${action} to tilt mode`, message: `Buzzer ID ${buzzerId} ${action} to tilt mode`
timestamp: new Date().toISOString()
})); }));
console.log(`[INFO] Tilt status updated: ${tiltList.length} buzzers in tilt mode`); console.log(`[INFO] Tilt status updated: ${tiltList.length} buzzers in tilt mode`);
@@ -106,8 +111,7 @@ client.on('message', (topic, message) => {
status: "received", status: "received",
action: status, action: status,
buzzer_id: buzzer_id, buzzer_id: buzzer_id,
message: `Tilt command '${status}' received for buzzer ID ${buzzer_id}`, message: `Tilt command '${status}' received for buzzer ID ${buzzer_id}`
timestamp: new Date().toISOString()
})); }));
// Send the updated tilt status to all components // Send the updated tilt status to all components
@@ -131,8 +135,7 @@ client.on('message', (topic, message) => {
client.publish(`vulture/buzzer/confirmation/${buzzerId}`, JSON.stringify({ client.publish(`vulture/buzzer/confirmation/${buzzerId}`, JSON.stringify({
status: "received", status: "received",
buzzer_id: buzzerId, buzzer_id: buzzerId,
message: `Buzzer ID ${buzzerId} received (Color: ${color})`, message: `Buzzer ID ${buzzerId} received (Color: ${color})`
timestamp: new Date().toISOString()
})); }));
// Ignore if the buzzer is in tilt mode, but notify this event // Ignore if the buzzer is in tilt mode, but notify this event
@@ -143,8 +146,7 @@ client.on('message', (topic, message) => {
client.publish(`vulture/buzzer/tilt/ignored/${buzzerId}`, JSON.stringify({ client.publish(`vulture/buzzer/tilt/ignored/${buzzerId}`, JSON.stringify({
status: "tilt_ignored", status: "tilt_ignored",
buzzer_id: buzzerId, buzzer_id: buzzerId,
message: `Buzzer ID ${buzzerId} is in tilt mode and ignored.`, message: `Buzzer ID ${buzzerId} is in tilt`
timestamp: new Date().toISOString()
})); }));
return; return;
} }
@@ -154,8 +156,7 @@ client.on('message', (topic, message) => {
buzzer_id: buzzerId, buzzer_id: buzzerId,
color: color, color: color,
status: buzzerActive ? "blocked" : "free", status: buzzerActive ? "blocked" : "free",
message: `Activity detected on buzzer ID ${buzzerId} (Color: ${color})`, message: `Activity detected on buzzer ID ${buzzerId} (Color: ${color})`
timestamp: new Date().toISOString()
})); }));
if (!buzzerActive) { if (!buzzerActive) {
@@ -176,8 +177,7 @@ client.on('message', (topic, message) => {
status: "blocked", status: "blocked",
buzzer_id: buzzerId, buzzer_id: buzzerId,
color: color, color: color,
message: `Buzzer activated by ID ${buzzerId} (Color: ${color})`, message: `Buzzer activated by ID ${buzzerId} (Color: ${color})`
timestamp: new Date().toISOString()
})); }));
console.log(`[INFO] Buzzers blocked and notification sent`); console.log(`[INFO] Buzzers blocked and notification sent`);
@@ -188,19 +188,12 @@ client.on('message', (topic, message) => {
if (topic === 'vulture/buzzer/unlock') { if (topic === 'vulture/buzzer/unlock') {
console.log('[INFO] Buzzer unlock requested'); console.log('[INFO] Buzzer unlock requested');
// Confirm receipt of unlock command
client.publish('vulture/buzzer/unlock/confirmation', JSON.stringify({
status: "received",
message: "Buzzer unlock command received.",
timestamp: new Date().toISOString()
}));
// Notify the light manager to change to the team's color // Notify the light manager to change to the team's color
client.publish('vulture/light/change', JSON.stringify({ client.publish('vulture/light/change', JSON.stringify({
color: "#FFFFFF", color: "#FFFFFF",
effect: 'rainbow' effect: 'rainbow'
})); }));
// Reset buzzer manager state // Reset buzzer manager state
buzzerActive = false; buzzerActive = false;
buzzerThatPressed = null; buzzerThatPressed = null;
@@ -208,8 +201,7 @@ client.on('message', (topic, message) => {
// Notify all components of buzzer unlock // Notify all components of buzzer unlock
client.publish('vulture/buzzer/status', JSON.stringify({ client.publish('vulture/buzzer/status', JSON.stringify({
status: "unblocked", status: "unblocked",
message: "Buzzers unblocked and ready for activation.", message: "Buzzers unblocked and ready for activation."
timestamp: new Date().toISOString()
})); }));
console.log('[INFO] Buzzers unblocked and notification sent'); console.log('[INFO] Buzzers unblocked and notification sent');

View File

@@ -1,12 +1,13 @@
const path = require('path');
const ping = require('ping'); const ping = require('ping');
const mqtt = require('mqtt'); const mqtt = require('mqtt');
const fs = require('fs'); const fs = require('fs');
// Lecture du fichier de configuration // Lecture du fichier de configuration
const config = JSON.parse(fs.readFileSync('\services\\config\\config_network.json', 'utf8')); const config = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/configuration.json'), 'utf8'));
// Extraction des informations de config // Extraction des informations de config
const { hosts: { buzzers: { IP: buzzerIPs, MQTTconfig: { mqttHost, mqttTopic } } } } = config; const { mqttHost, hosts: { buzzers: { IP: buzzerIPs, MQTTconfig: { mqttTopic } } } } = config;
// Connexion au broker MQTT // Connexion au broker MQTT
const client = mqtt.connect(mqttHost); const client = mqtt.connect(mqttHost);

View File

@@ -1,18 +0,0 @@
{
"services": {
"mqttHost": "mqtt://192.168.1.30",
"score":{
"MQTTconfig":{
"mqttScoreTopic": "game/score",
"mqttScoreChangeTopic": "game/score/update"
}
},
"quizzcollector":{
"MQTTconfig":{
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
"mqttQuizzCollectorCmdTopic": "game/quizz-collector/cmd"
}
}
}
}

View File

@@ -1,17 +0,0 @@
{
"hosts": {
"buzzers":{
"IP":{
"redBuzzerIP": "8.8.8.6",
"blueBuzzerIP": "8.8.8.8",
"greenBuzzerIP": "8.8.8.8",
"yellowBuzzerIP": "8.8.8.8"
},
"MQTTconfig":{
"mqttHost": "mqtt://192.168.1.28",
"mqttTopic": "buzzer/watcher"
}
}
}
}

View File

@@ -0,0 +1,30 @@
{
"mqttHost": "mqtt://192.168.1.201",
"services": {
"score": {
"MQTTconfig": {
"mqttScoreTopic": "game/score",
"mqttScoreChangeTopic": "game/score/update"
}
},
"quizzcollector": {
"MQTTconfig": {
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
"mqttQuizzCollectorCmdTopic": "game/quizz-collector/cmd"
}
}
},
"hosts": {
"buzzers": {
"IP": {
"redBuzzerIP": "8.8.8.6",
"blueBuzzerIP": "8.8.8.8",
"greenBuzzerIP": "8.8.8.8",
"yellowBuzzerIP": "8.8.8.8"
},
"MQTTconfig": {
"mqttTopic": "buzzer/watcher"
}
}
}
}

View File

@@ -1,11 +1,12 @@
const fs = require('fs'); const fs = require('fs');
const mqtt = require('mqtt'); const mqtt = require('mqtt');
const path = require('path');
// Lecture du fichier de configuration // Lecture du fichier de configuration
const config = JSON.parse(fs.readFileSync('\services\\config\\config_game.json', 'utf8')); const config = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/configuration.json'), 'utf8'));
// Extraction des informations de config // Extraction des informations de config
const { services: { mqttHost, quizzcollector: { MQTTconfig: { mqttQuizzCollectorListTopic, mqttQuizzCollectorCmdTopic } } } } = config; const { mqttHost, services: { quizzcollector: { MQTTconfig: { mqttQuizzCollectorListTopic, mqttQuizzCollectorCmdTopic } } } } = config;
// Configuration // Configuration
const folderPath = 'quizz'; // Remplace par le chemin de ton dossier const folderPath = 'quizz'; // Remplace par le chemin de ton dossier
@@ -40,7 +41,7 @@ function Collect() {
} }
console.log('Dossiers trouvés:', files); console.log('Dossiers trouvés:', files);
const message = JSON.stringify( files ); const message = JSON.stringify(files);
client.publish(mqttQuizzCollectorListTopic, message, { qos: 1 }, (err) => { client.publish(mqttQuizzCollectorListTopic, message, { qos: 1 }, (err) => {
if (err) { if (err) {
console.error('Erreur lors de la publication MQTT:', err); console.error('Erreur lors de la publication MQTT:', err);

View File

@@ -1,9 +1,9 @@
const args = process.argv; const args = process.argv;
// Vérification si un paramètre est passé, si c'est le cas c'est qu'on fournis un fichier de score pour reprendre le jeu la ou il était // Vérification si un paramètre est passé, si c'est le cas c'est qu'on fournis un fichier de score pour reprendre le jeu la ou il était
if (args[2] !== undefined){ if (args[2] !== undefined) {
global.ScoreFile = args[2] global.ScoreFile = args[2]
}else{ } else {
global.ScoreFile = "XXX.json" global.ScoreFile = "XXX.json"
} }
@@ -50,32 +50,32 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
const initialContent = { const initialContent = {
"TEAM": { "TEAM": {
"Red": { "Red": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
}, },
"Blue": { "Blue": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
}, },
"Yellow": { "Yellow": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
}, },
"Green": { "Green": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
} }
} }
}; };
@@ -132,11 +132,15 @@ function updateTeamTotalScore(teamColor, points) {
// Lecture du fichier de configuration // Lecture du fichier de configuration
const config = JSON.parse(fs.readFileSync('\services\\config\\config_game.json', 'utf8')); const configPath = path.join(__dirname, '../config/configuration.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Extraction des informations de config // Extraction des informations de config
const { services: { mqttHost, score: { MQTTconfig: { mqttScoreTopic, mqttScoreChangeTopic } } } } = config; const { mqttHost, services: { score: { MQTTconfig: { mqttScoreTopic, mqttScoreChangeTopic } } } } = config;
console.log(mqttScoreChangeTopic) console.log("DEBUG: Config loaded from:", configPath);
console.log("DEBUG: MQTT Host:", mqttHost);
console.log("DEBUG: Topics:", mqttScoreTopic, mqttScoreChangeTopic);
// Connexion au broker MQTT // Connexion au broker MQTT
const client = mqtt.connect(mqttHost); const client = mqtt.connect(mqttHost);
@@ -156,6 +160,8 @@ client.on('message', (topic, message) => {
let process; let process;
let Team; let Team;
let Action; let Action;
let TotalScore = null;
let RoundScore = null;
try { try {
// Analyse du message reçu // Analyse du message reçu
@@ -168,63 +174,82 @@ client.on('message', (topic, message) => {
if (payload && typeof payload === 'object') { if (payload && typeof payload === 'object') {
// Extraire la clé (la couleur) et la valeur associée // Extraire la clé (la couleur) et la valeur associée
Team = Object.keys(payload)[0]; // La première (et unique) clé Team = Object.keys(payload)[0]; // La première (et unique) clé
Action = payload[Team]; // La valeur associée let value = payload[Team]; // La valeur associée
//console.log(`Team: ${Team}, Action: ${Action}`);
if (typeof value === 'object') {
// Mode SET (valeur absolue)
if (value.hasOwnProperty('Total')) TotalScore = parseInt(value.Total, 10);
if (value.hasOwnProperty('Round')) RoundScore = parseInt(value.Round, 10);
Action = "SET";
} else {
// Mode ADD (relatif)
Action = value;
}
process = true; process = true;
} else { } else {
console.error(typeof payload); console.error(typeof payload);
process = false; process = false;
} }
if (process === true){ if (process === true) {
let currentScore = 0; if (Action === "SET") {
let change = 0 ; // Mise à jour absolue
switch (Team){ updateTeamScoreAbsolute(Team, TotalScore, RoundScore);
case "Red": } else {
change = parseInt(Action, 10); // Convertit 'action' en entier // Mise à jour relative (existant)
if (!isNaN(change)) { let change = parseInt(Action, 10);
updateTeamTotalScore("Red", change) if (!isNaN(change)) {
} else { updateTeamTotalScore(Team, change);
console.error(`Action invalide : ${action}`); } else {
} console.error(`Action invalide : ${Action}`);
break; }
case "Blue":
change = parseInt(Action, 10); // Convertit 'action' en entier
if (!isNaN(change)) {
updateTeamTotalScore("Blue", change)
} else {
console.error(`Action invalide : ${action}`);
}
break;
case "Green":
change = parseInt(Action, 10); // Convertit 'action' en entier
if (!isNaN(change)) {
updateTeamTotalScore("Green", change)
} else {
console.error(`Action invalide : ${action}`);
}
break;
case "Yellow":
change = parseInt(Action, 10); // Convertit 'action' en entier
if (!isNaN(change)) {
updateTeamTotalScore("Yellow", change)
} else {
console.error(`Action invalide : ${action}`);
}
break;
} }
} }
}); });
// Fonction pour mettre à jour le score d'une équipe (Absolu)
function updateTeamScoreAbsolute(teamColor, totalScore, roundScore) {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error("Erreur de lecture du fichier :", err);
return;
}
try {
const jsonData = JSON.parse(data);
if (!jsonData.TEAM.hasOwnProperty(teamColor)) {
console.error(`L'équipe ${teamColor} n'existe pas.`);
return;
}
if (totalScore !== null && !isNaN(totalScore)) {
jsonData.TEAM[teamColor].TotalScore = totalScore;
}
if (roundScore !== null && !isNaN(roundScore)) {
jsonData.TEAM[teamColor].RoundScore = roundScore;
}
console.log(`Mise à jour absolue pour ${teamColor} -> Total: ${jsonData.TEAM[teamColor].TotalScore}, Round: ${jsonData.TEAM[teamColor].RoundScore}`);
client.publish(mqttScoreTopic, JSON.stringify(jsonData));
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
if (err) console.error("Erreur d'écriture :", err);
});
} catch (parseErr) {
console.error("Erreur JSON :", parseErr);
}
});
}
(async () => { (async () => {
while (true) { while (true) {
console.log("Boucle en arrière-plan"); console.log("Boucle en arrière-plan");
await new Promise((resolve) => setTimeout(resolve, 2000)); // Pause de 2 secondes await new Promise((resolve) => setTimeout(resolve, 2000)); // Pause de 2 secondes
//client.publish(mqttScoreTopic, JSON.stringify(global.jsonData)); //client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
} }
})(); })();
client.on('error', (error) => { client.on('error', (error) => {
console.error('Erreur de connexion au broker MQTT:', error.message); console.error('Erreur de connexion au broker MQTT:', error.message);

View File

@@ -2,7 +2,7 @@
const mqtt = require('mqtt'); const mqtt = require('mqtt');
// Configuration du broker MQTT et de WLED // Configuration du broker MQTT et de WLED
const brokerUrl = 'mqtt://localhost'; // Change ce lien si nécessaire const brokerUrl = 'mqtt://nanomq'; // Change ce lien si nécessaire
const clientId = 'light_manager_wled'; const clientId = 'light_manager_wled';
const wledTopicBase = 'wled/all'; // Le topic de base pour ton ruban WLED const wledTopicBase = 'wled/all'; // Le topic de base pour ton ruban WLED
const options = { const options = {