Dev #17
+36
-11
@@ -4,12 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
|
||||
# Les jobs "test-*"/"lint-*" tournent sur CHAQUE push (main et dev) :
|
||||
# jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les outils
|
||||
# qualité, rien n'empêchait un commit cassé d'atteindre la production (voir
|
||||
# l'audit qualité de la Phase 0 du plan). "build-and-push"/"deploy", eux,
|
||||
# restent réservés à main (via le filtre "if" sur gitea.ref) — un push sur
|
||||
# dev ne doit jamais redéployer la prod, seulement faire tourner tests/lint.
|
||||
# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et
|
||||
# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les
|
||||
# outils qualité, rien n'empêchait un commit cassé d'atteindre la
|
||||
# production (voir l'audit qualité de la Phase 0 du plan). "build-and-
|
||||
# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur
|
||||
# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement
|
||||
# faire tourner tests/lint/sonar.
|
||||
#
|
||||
# lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux
|
||||
# (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous
|
||||
@@ -17,11 +18,11 @@ on:
|
||||
# les hooks pre-commit ne protègent que la machine du committeur, jamais
|
||||
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
|
||||
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité.
|
||||
#
|
||||
# SonarQube (job CI + service prod) retiré pour l'instant — voir
|
||||
# CODE_QUALITY.md pour le contexte (accès instance locale/prod bloqué par
|
||||
# des soucis d'infra réseau, mis de côté volontairement).
|
||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
|
||||
# lui, reste NON-BLOQUANT (continue-on-error) — remis en place le
|
||||
# 18/09/2026 une fois l'instance prod opérationnelle (voir CODE_QUALITY.md
|
||||
# pour l'historique du retrait temporaire), le temps que le rapport de
|
||||
# code smells soit entièrement trié.
|
||||
|
||||
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
||||
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
||||
@@ -32,6 +33,8 @@ on:
|
||||
# DEPLOY_USER utilisateur SSH sur ce serveur
|
||||
# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur
|
||||
# DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine)
|
||||
# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token
|
||||
# sur sonar.forgebase.fr) — jamais le mot de passe admin.
|
||||
#
|
||||
# Utilise directement docker/ssh/scp en ligne de commande plutôt que des
|
||||
# actions du marketplace, pour ne pas dépendre de la disponibilité de
|
||||
@@ -131,6 +134,28 @@ jobs:
|
||||
RUN npm run lint:css
|
||||
DOCKERFILE
|
||||
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
# Non-bloquant pendant cette premiere periode (voir le bloc de
|
||||
# commentaires en tete de fichier) — un echec ici n'empeche jamais
|
||||
# build-and-push/deploy, contrairement a lint-python/lint-js.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Analyse SonarQube (rapport seul, non-bloquant)
|
||||
run: |
|
||||
# Meme neutralisation de .dockerignore que test-python : sonar-
|
||||
# project.properties couvre aussi tests/ (sonar.tests).
|
||||
mv .dockerignore .dockerignore.disabled-for-ci
|
||||
docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE'
|
||||
FROM sonarsource/sonar-scanner-cli:latest
|
||||
WORKDIR /usr/src
|
||||
COPY . .
|
||||
DOCKERFILE
|
||||
docker run --rm forge-sonar:${{ gitea.sha }} \
|
||||
-Dsonar.host.url=https://sonar.forgebase.fr \
|
||||
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
build-and-push:
|
||||
needs: [test-python, test-js, lint-python, lint-js]
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
|
||||
+11
-6
@@ -81,8 +81,10 @@ ignore de fichier entier ou de règle globale — voir section 4.
|
||||
- `profile = "jinja"`, `max_line_length = 160`, `indent = 2`.
|
||||
- H021 (styles inline) : **backlog assumé, pas une exception corrigée** — voir section 6, ne pas confondre avec les vraies exceptions de la section 5.
|
||||
|
||||
### SonarQube — retiré pour l'instant
|
||||
Le job CI `sonarqube` (`.gitea/workflows/deploy.yml`) et le service prod `sonarqube`/`sonar-postgres` (`docker-compose.prod.yml`) ont été retirés le 18/09/2026 : l'accès au dashboard (local WSL2 et prod derrière Caddy) est resté bloqué par des soucis d'infrastructure réseau (redirection de port WSL2/pare-feu Hyper-V côté local, réseau Docker partagé avec Caddy pas encore en place côté prod) sans lien avec le code du moteur — mis de côté volontairement plutôt que de continuer à bloquer sur de l'infra. Le lot "modernisation JS" (voir `docs/JS_MODERNIZATION_PLAN.md`) s'est arrêté après le lot 3 (`S2486`) pour cette même raison : les lots 4+ dépendent de scores Sonar exacts (complexité cognitive notamment) qu'aucun proxy fiable ne remplace. À reprendre une fois l'accès rétabli — voir section 6.
|
||||
### SonarQube
|
||||
- **CI (`sonar.forgebase.fr`)** : job `sonarqube` (`.gitea/workflows/deploy.yml`), scan à chaque push, **non-bloquant** (`continue-on-error: true`) le temps que le rapport soit entièrement trié. Retiré le 18/09/2026 (voir historique ci-dessous), remis en place le 18/09/2026 une fois l'instance prod confirmée opérationnelle.
|
||||
- **Service prod** (`docker-compose.prod.yml`) : reste retiré côté dépôt pour l'instant — l'instance prod opérationnelle n'est pas (encore) pilotée par ce fichier de ce côté-ci ; à réintégrer explicitement si besoin.
|
||||
- **Historique du retrait temporaire (16-18/09/2026)** : l'accès au dashboard (local WSL2 et prod derrière Caddy) était resté bloqué par des soucis d'infrastructure réseau (redirection de port WSL2/pare-feu Hyper-V côté local, réseau Docker partagé avec Caddy pas encore en place côté prod à ce moment) sans lien avec le code du moteur. Le lot "modernisation JS" (voir `docs/JS_MODERNIZATION_PLAN.md`) s'était arrêté après le lot 3 (`S2486`) pour cette même raison — les lots 4+ dépendent de scores Sonar exacts (complexité cognitive notamment) qu'aucun proxy fiable ne remplace parfaitement (le SonarLint de l'IDE a servi de solution de contournement fonctionnelle en attendant, voir lots 4 en cours directement sur les fichiers ouverts dans l'éditeur).
|
||||
|
||||
## 3. Lancer les checks en local
|
||||
|
||||
@@ -106,7 +108,7 @@ python -m pytest tests/ -q
|
||||
node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
||||
```
|
||||
|
||||
SonarQube (local et CI/prod) retiré pour l'instant — voir section 2, sous-section "SonarQube — retiré pour l'instant".
|
||||
SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloquante), instance locale toujours bloquée (voir historique dans cette même sous-section).
|
||||
|
||||
## 4. Procédure de justification d'une exception
|
||||
|
||||
@@ -137,15 +139,18 @@ SonarQube (local et CI/prod) retiré pour l'instant — voir section 2, sous-sec
|
||||
| `static/js/play/bindings.js:179` (`gameData = newData`) | `javascript:S2703` | Pattern volontaire de scripts globaux (pas des modules ES) : `gameData`/`screensData` sont déclarés une fois par `let` dans le `<script>` inline de `templates/play.html`, chargé avant tous les `static/js/play/*.js` (ordre séquentiel vérifié, pas de `defer`/`async`). Réassignation légitime d'une variable déjà déclarée dans un scope global partagé — déjà documenté dans `.eslintrc.json` (`"gameData": "writable"`). | Session du 16/09/2026 |
|
||||
| `static/js/play/bindings.js:180` (`screensData = gameData.screens`) | `javascript:S2703` | Même bloc, même pattern, même fichier de déclaration que `gameData` ci-dessus : `let screensData = gameData.screens;` posé dans le même `<script>` inline de `templates/play.html:145` (juste après `gameData:144`), chargé avant `bindings.js` — déjà documenté dans `.eslintrc.json` (`"screensData": "writable"`). Lot 2 "modernisation JS". | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations, toutes dans ce même fichier | `javascript:S2703` | Déclarée ET réassignée exclusivement à l'intérieur de `collision-rules-editor.js` (aucun autre fichier n'y touche, vérifié par grep exhaustif) — Sonar la signale seulement parce que ce fichier est un script classique sans wrapper de module (comme tout `static/js/`), pas parce qu'un ordre de chargement entre fichiers serait en jeu ; c'est le cas le plus simple des 4 (état privé à un seul fichier). Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
|
||||
| `static/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
|
||||
| 31 sites `\|safe` (`templates/scene_edit.html`, `templates/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 31 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 |
|
||||
| `static/js/play/offline/filter-repeater-rows.js:10,11`, `static/js/screen_edit/panel-init.js:254,261`, `static/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/screen_edit/tree-panels.js:162` (`dragstart` galerie d'icônes), `:295` (`dragstart` arborescence) | `javascript:S2486` | `e.dataTransfer.setData(...)` sert uniquement à satisfaire l'exigence cross-navigateur de l'API HTML5 Drag (au moins un type MIME posé) — vérifié que les deux `drop` correspondants (lignes 177-182 et 320-344) lisent l'id glissé depuis une variable JS module (`draggedIconClass`/`treeDragElementId`), jamais `e.dataTransfer.getData(...)` : un échec de `setData` n'a donc aucun effet sur le comportement réel du glisser-déposer. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/offline/xapi-client.js:72` (`forgeXapiActor`) | `javascript:S2486` | API SCORM absente ou non conforme — attendu hors d'un vrai LMS (ex. prévisualisation) — repli déjà en place sur un acteur anonyme générique. Comportement déjà documenté par le commentaire du bloc. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/screens.js:89` (`runScreenBackgroundMusic`), `static/js/play/actions.js:333` (action "jouer_son") | `javascript:S2486` | `.play().catch(() => {})` — échec attendu du navigateur (lecture audio automatique bloquée tant qu'aucune interaction utilisateur n'a eu lieu), jamais une erreur applicative à signaler. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/screens.js:192` (`applyAnimationClip`, clip `sprite`), `static/js/play/actions.js:340` (action `jouer_animation_sprite`) | `javascript:S2486` | **Corrigé, pas seulement documenté** : `JSON.parse(custom_keyframes / data_value)` invalide laissait `spriteData` retomber silencieusement sur `{}` (aucune animation jouée) sans aucun signal — `custom_keyframes`/`data_value` sont produits par l'éditeur, jamais tapés à la main, donc un JSON invalide ici trahit presque toujours un bug côté éditeur plutôt qu'une simple erreur de saisie. Un `console.warn('configuration sprite invalide', e)` a été ajouté dans les deux `catch` : signal devtools pour le créateur en test, **comportement joueur inchangé** (l'animation reste silencieusement absente). Couvert par un nouveau test (`static/js/play/__tests__/actions.test.js`, `runActionNode — jouer_animation_sprite avec data_value JSON invalide`) qui vérifie à la fois l'absence de crash/rendu cassé ET l'appel du `console.warn`. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||
| `static/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
| `static/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||
|
||||
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
|
||||
|
||||
@@ -180,5 +185,5 @@ Résultat mesuré : **1.38 ms à n=1 000 000** (le run à n=100 000 a affiché 1
|
||||
## 6. Backlog qualité
|
||||
|
||||
- **djLint H021 (49 occurrences, 6 templates)** — styles inline à remplacer par un système de modèles/classes CSS réutilisables. **Priorité immédiate** après la clôture de ce dispositif, avant ou après la Phase 4 CI selon décision à prendre le moment venu. Ce n'est **pas** une exception documentée en section 5 : c'est une dette assumée et non traitée, à corriger, pas à justifier indéfiniment.
|
||||
- **Code smells SonarQube (421 restants)** — lot JS "modernisation" (357 issues), plan détaillé et validé dans `docs/JS_MODERNIZATION_PLAN.md` (répartition par règle, classification mécanique/cas par cas, outillage vérifié, 9 lots) ; lots 1-3 (`S8786`, `S2703`, `S2486`) faits, lots 4-9 **en pause** — dépendent de scores Sonar exacts (voir section 2). Reste aussi la complexité cognitive Python et la duplication, non encore triées.
|
||||
- **SonarQube (local + CI/prod) retiré, à réintégrer** — job CI et service prod retirés le 18/09/2026 (voir section 2). Une fois l'accès dashboard rétabli (local et/ou prod derrière Caddy) : reconfigurer le job `.gitea/workflows/deploy.yml`/le service `docker-compose.prod.yml`, reprendre le lot JS "modernisation" au lot 4, puis retirer `continue-on-error: true` une fois le rapport de code smells trié et `Web:S5247` résolu ou accepté comme limitation permanente.
|
||||
- **Code smells SonarQube** — lot JS "modernisation" (357 issues d'origine), plan détaillé et validé dans `docs/JS_MODERNIZATION_PLAN.md` (répartition par règle, classification mécanique/cas par cas, outillage vérifié, 9 lots) ; lots 1-3 (`S8786`, `S2703`, `S2486`) faits. Lots 4+ repris le 18/09/2026 via SonarLint (IDE) fichier par fichier — pas de liste exhaustive centralisée pendant que le dashboard serveur restait inaccessible, donc pas de rescan global de confirmation avant que la CI `sonarqube` (voir ci-dessous) tourne à nouveau. Reste aussi la complexité cognitive Python et la duplication, non encore triées.
|
||||
- **CI `sonarqube` restaurée (18/09/2026)** — job remis en place dans `.gitea/workflows/deploy.yml` (non-bloquant) une fois l'instance prod confirmée opérationnelle. Le service `docker-compose.prod.yml` reste volontairement absent de ce dépôt (prod pilotée autrement de ce côté). Une fois le premier scan CI passé : comparer son rapport à ce qui a déjà été corrigé fichier par fichier via SonarLint, pour confirmer qu'aucun doublon/oubli, avant de retirer `continue-on-error: true`.
|
||||
|
||||
@@ -106,8 +106,8 @@ function iaChatRefreshAfterTurn() {
|
||||
// dessus plutôt que de rejouer un script inline.
|
||||
const canvas = document.getElementById('canvas');
|
||||
if (canvas) {
|
||||
const newWidth = parseFloat(canvas.style.width);
|
||||
const newHeight = parseFloat(canvas.style.height);
|
||||
const newWidth = Number.parseFloat(canvas.style.width);
|
||||
const newHeight = Number.parseFloat(canvas.style.height);
|
||||
if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
||||
if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
||||
}
|
||||
@@ -225,7 +225,7 @@ function iaChatLoadMessages(conversationId) {
|
||||
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`)
|
||||
.then((r) => r.json())
|
||||
.then((messages) => {
|
||||
if (!messages || !messages.length) {
|
||||
if (!messages?.length) {
|
||||
log.innerHTML = iaChatEmptyStateHtml();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,32 @@ global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
const {
|
||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
applyElementProperty, applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
} = require('../actions.js');
|
||||
|
||||
function fakeImg() {
|
||||
return { src: '' };
|
||||
}
|
||||
|
||||
// Mock DOM minimal pour applyElementProperty() — classList/style/dataset
|
||||
// réels (Set + objet), pas juste des espions, pour pouvoir vérifier l'état
|
||||
// après coup (ex. "la classe a bien été retirée", pas seulement "la
|
||||
// méthode a été appelée").
|
||||
function fakeElement() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
style: { display: '', visibility: '' },
|
||||
dataset: {},
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
toggle: (c) => { if (classes.has(c)) classes.delete(c); else classes.add(c); },
|
||||
has: (c) => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => {
|
||||
const el = fakeImg();
|
||||
const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true };
|
||||
@@ -89,6 +108,113 @@ test('resolveSpriteFrames — résout depuis gameData.personnage_animations par
|
||||
// maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne
|
||||
// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent
|
||||
// désormais à [0, scene_width/height - sa propre taille].
|
||||
// Couverture de TOUTES les branches d'applyElementProperty() (39 -> lot 4
|
||||
// "modernisation JS", S3776) AVANT tout refactor, pour garantir que
|
||||
// l'extraction en sous-fonctions ne change aucun comportement — voir
|
||||
// CODE_QUALITY.md.
|
||||
test('applyElementProperty — "contenu" : texte littéral, null/undefined -> vidé', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'contenu', 'Bonjour');
|
||||
assert.equal(el.textContent, 'Bonjour');
|
||||
applyElementProperty(el, 'contenu', null);
|
||||
assert.equal(el.textContent, '');
|
||||
});
|
||||
|
||||
test('applyElementProperty — "surbrillance" : toggle/off/valeur par défaut (activer)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'surbrillance', 'toggle');
|
||||
assert.ok(el.classList.has('forgeHighlight'));
|
||||
applyElementProperty(el, 'surbrillance', 'toggle');
|
||||
assert.ok(!el.classList.has('forgeHighlight'));
|
||||
applyElementProperty(el, 'surbrillance', 'on');
|
||||
assert.ok(el.classList.has('forgeHighlight'));
|
||||
applyElementProperty(el, 'surbrillance', 'off');
|
||||
assert.ok(!el.classList.has('forgeHighlight'));
|
||||
});
|
||||
|
||||
test('applyElementProperty — "desactive" : toggle/off/valeur par défaut (désactiver)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'desactive', 'toggle');
|
||||
assert.ok(el.classList.has('forgeDisabled'));
|
||||
applyElementProperty(el, 'desactive', 'toggle');
|
||||
assert.ok(!el.classList.has('forgeDisabled'));
|
||||
applyElementProperty(el, 'desactive', 'on');
|
||||
assert.ok(el.classList.has('forgeDisabled'));
|
||||
applyElementProperty(el, 'desactive', 'off');
|
||||
assert.ok(!el.classList.has('forgeDisabled'));
|
||||
});
|
||||
|
||||
test('applyElementProperty — "visibilite" : toggle (selon état actuel), masque, invisible, valeur par défaut (visible)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'visibilite', 'toggle');
|
||||
assert.equal(el.style.display, 'none');
|
||||
applyElementProperty(el, 'visibilite', 'toggle');
|
||||
assert.equal(el.style.display, '');
|
||||
applyElementProperty(el, 'visibilite', 'masque');
|
||||
assert.equal(el.style.display, 'none');
|
||||
applyElementProperty(el, 'visibilite', 'invisible');
|
||||
assert.equal(el.style.display, '');
|
||||
assert.equal(el.style.visibility, 'hidden');
|
||||
applyElementProperty(el, 'visibilite', 'visible');
|
||||
assert.equal(el.style.display, '');
|
||||
assert.equal(el.style.visibility, '');
|
||||
});
|
||||
|
||||
test('applyElementProperty — "orientation" : toggle/gauche/valeur par défaut (droite)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'orientation', 'toggle');
|
||||
assert.ok(el.classList.has('forgeFlipped'));
|
||||
applyElementProperty(el, 'orientation', 'toggle');
|
||||
assert.ok(!el.classList.has('forgeFlipped'));
|
||||
applyElementProperty(el, 'orientation', 'gauche');
|
||||
assert.ok(el.classList.has('forgeFlipped'));
|
||||
applyElementProperty(el, 'orientation', 'droite');
|
||||
assert.ok(!el.classList.has('forgeFlipped'));
|
||||
});
|
||||
|
||||
test('applyElementProperty — "pos_x_relatif"/"pos_y_relatif" : ajoute au pixel/pourcentage actuel, ignore une valeur non numérique', () => {
|
||||
const el = fakeElement();
|
||||
el.style.left = '10%';
|
||||
applyElementProperty(el, 'pos_x_relatif', '5');
|
||||
assert.equal(el.style.left, '15%');
|
||||
el.style.top = '20%';
|
||||
applyElementProperty(el, 'pos_y_relatif', '-5');
|
||||
assert.equal(el.style.top, '15%');
|
||||
applyElementProperty(el, 'pos_x_relatif', 'pas-un-nombre');
|
||||
assert.equal(el.style.left, '15%', 'valeur non numérique : aucun changement');
|
||||
});
|
||||
|
||||
test('applyElementProperty — propriété inconnue ou valeur null/undefined : aucun effet', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'propriete_qui_n_existe_pas', 'x');
|
||||
applyElementProperty(el, 'width', undefined);
|
||||
applyElementProperty(el, 'width', null);
|
||||
assert.equal(el.style.width, undefined, 'jamais touché');
|
||||
});
|
||||
|
||||
test('applyElementProperty — valeur "a||b" (va-et-vient) : bascule à chaque appel, persiste via dataset', () => {
|
||||
// Premier appel (dataset vide, "pas encore à la 2e valeur") -> bascule
|
||||
// sur la SECONDE valeur (parts[1]) et mémorise l'état "à la 2e" (voir
|
||||
// le code : atSecond=false -> next=parts[1], dataset mis à '1').
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'width', '10||90');
|
||||
assert.equal(el.style.width, '90%');
|
||||
assert.equal(el.dataset.togglewidth, '1');
|
||||
applyElementProperty(el, 'width', '10||90');
|
||||
assert.equal(el.style.width, '10%');
|
||||
assert.equal(el.dataset.togglewidth, '0');
|
||||
applyElementProperty(el, 'width', '10||90');
|
||||
assert.equal(el.style.width, '90%');
|
||||
});
|
||||
|
||||
test('applyElementProperty — valeur directe (pas de "||") : assignation simple avec suffixe', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'width', '42');
|
||||
assert.equal(el.style.width, '42%');
|
||||
applyElementProperty(el, 'bg_color', '#ff0000');
|
||||
assert.equal(el.style.backgroundColor, '#ff0000');
|
||||
});
|
||||
|
||||
function withScreensData(screenData, fn) {
|
||||
const previousScreens = global.screensData;
|
||||
const previousId = global.window.currentScreenId;
|
||||
@@ -97,6 +223,276 @@ function withScreensData(screenData, fn) {
|
||||
try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; }
|
||||
}
|
||||
|
||||
// Couverture de TOUTES les branches de runActionNode() (58 -> lot 4
|
||||
// "modernisation JS", S3776) AVANT tout refactor — un seul appel par
|
||||
// action_type, avec des dépendances globales (showScreen, fetch,
|
||||
// FORGE_PLAY_URLS, les fonctions forgeApply*Offline...) remplacées par
|
||||
// des espions qui enregistrent l'appel plutôt que par leur vraie
|
||||
// implémentation (déjà testée ailleurs : screens.js, apply-actions.js).
|
||||
// Objectif : garantir que l'extraction en sous-fonctions n'envoie pas le
|
||||
// mauvais argument ou n'appelle pas la mauvaise fonction — pas retester
|
||||
// le comportement de ces dépendances elles-mêmes.
|
||||
const ACTION_GLOBAL_KEYS = [
|
||||
'showScreen', 'screenIndexById', 'applyOpenRowBindings', 'bindClicks', 'bindHoverTexts', 'bindHoverTriggers',
|
||||
'refreshRuntimeData', 'forgeApplyDataActionOffline', 'forgeApplyVariableActionOffline',
|
||||
'forgeApplyScoreActionOffline', 'forgeApplyStatusActionOffline', 'forgeApplyAddRowActionOffline',
|
||||
'FORGE_PLAY_URLS', 'fetch', 'Audio',
|
||||
];
|
||||
|
||||
// `fn` peut être async (branches qui font un vrai fetch) — restaurer les
|
||||
// globales seulement APRÈS la résolution de son résultat (via .finally()
|
||||
// sur la promesse renvoyée, pas un await bloquant ici) est obligatoire :
|
||||
// un simple `try/finally` synchrone les restaurait pendant que la chaîne
|
||||
// .then() de runActionNode tournait encore (bug rencontré en écrivant ce
|
||||
// test : "refreshRuntimeData is not a function" alors que le stub était
|
||||
// bien posé — juste déjà retiré par le moment où le .then() s'exécutait).
|
||||
// Reste appelable SANS await pour les cas synchrones (pas de changement
|
||||
// de signature pour la majorité des tests déjà écrits).
|
||||
function withActionNodeGlobals(fn) {
|
||||
const calls = [];
|
||||
const previous = {};
|
||||
ACTION_GLOBAL_KEYS.forEach((key) => { previous[key] = global[key]; });
|
||||
const previousDispatch = global.window.dispatchGameEvent;
|
||||
const previousOffline = global.window.FORGE_OFFLINE;
|
||||
|
||||
global.showScreen = (id) => calls.push(['showScreen', id]);
|
||||
global.screenIndexById = (id) => global.screensData.findIndex((s) => s.id === id);
|
||||
global.applyOpenRowBindings = () => calls.push(['applyOpenRowBindings']);
|
||||
global.bindClicks = () => calls.push(['bindClicks']);
|
||||
global.bindHoverTexts = () => calls.push(['bindHoverTexts']);
|
||||
global.bindHoverTriggers = () => calls.push(['bindHoverTriggers']);
|
||||
global.window.dispatchGameEvent = (id) => calls.push(['dispatchGameEvent', id]);
|
||||
global.refreshRuntimeData = () => { calls.push(['refreshRuntimeData']); return Promise.resolve('refreshed'); };
|
||||
global.forgeApplyDataActionOffline = (gd, node) => calls.push(['forgeApplyDataActionOffline', node.id]);
|
||||
global.forgeApplyVariableActionOffline = (gd, node) => calls.push(['forgeApplyVariableActionOffline', node.id]);
|
||||
global.forgeApplyScoreActionOffline = (gd, node) => calls.push(['forgeApplyScoreActionOffline', node.id]);
|
||||
global.forgeApplyStatusActionOffline = (gd, node) => calls.push(['forgeApplyStatusActionOffline', node.id]);
|
||||
global.forgeApplyAddRowActionOffline = (gd, node) => { calls.push(['forgeApplyAddRowActionOffline', node.id]); return 4242; };
|
||||
global.FORGE_PLAY_URLS = {
|
||||
flowNodeRunData: '/run-data/999999',
|
||||
flowNodeRunVariable: '/run-variable/999999',
|
||||
flowNodeRunScore: '/run-score/999999',
|
||||
flowNodeRunStatus: '/run-status/999999',
|
||||
flowNodeRunAddRow: '/run-add-row/999999',
|
||||
};
|
||||
global.fetch = (url, opts) => {
|
||||
calls.push(['fetch', url, (opts && opts.method) || 'GET']);
|
||||
return Promise.resolve({ json: () => Promise.resolve({ row_id: 4343 }) });
|
||||
};
|
||||
global.Audio = function FakeAudio(src) {
|
||||
calls.push(['Audio', src]);
|
||||
return { play: () => ({ catch: () => {} }) };
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
ACTION_GLOBAL_KEYS.forEach((key) => { global[key] = previous[key]; });
|
||||
global.window.dispatchGameEvent = previousDispatch;
|
||||
global.window.FORGE_OFFLINE = previousOffline;
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = fn(calls);
|
||||
} catch (e) {
|
||||
restore();
|
||||
throw e;
|
||||
}
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.finally(restore);
|
||||
}
|
||||
restore();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('runActionNode — ecran_suivant : avance si un écran suivant existe, ne fait rien en fin de liste', () => {
|
||||
withScreensData({ id: 1 }, () => {
|
||||
global.screensData = [{ id: 1 }, { id: 2 }];
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_suivant' });
|
||||
assert.deepEqual(calls, [['showScreen', 2]]);
|
||||
});
|
||||
global.window.currentScreenId = 2;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_suivant' });
|
||||
assert.deepEqual(calls, [], 'déjà le dernier écran : aucun changement');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — ecran_precedent : recule si un écran précédent existe, ne fait rien en tête de liste', () => {
|
||||
global.screensData = [{ id: 1 }, { id: 2 }];
|
||||
global.window.currentScreenId = 2;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_precedent' });
|
||||
assert.deepEqual(calls, [['showScreen', 1]]);
|
||||
});
|
||||
global.window.currentScreenId = 1;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_precedent' });
|
||||
assert.deepEqual(calls, [], 'déjà le premier écran : aucun changement');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — aller_a : montre l\'écran cible si présent, ignore sans target_screen_id', () => {
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'aller_a', target_screen_id: 7 });
|
||||
assert.deepEqual(calls, [['showScreen', 7]]);
|
||||
});
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'aller_a' });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — ouvrir_ligne : mémorise la ligne cliquée, affiche l\'écran, rebranche les écouteurs', () => {
|
||||
global.window.lastClickedRowId = 5;
|
||||
global.window.lastClickedDefinitionId = 9;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ouvrir_ligne', target_screen_id: 3 });
|
||||
assert.deepEqual(global.window.currentOpenRow, { definitionId: 9, rowId: 5 });
|
||||
assert.deepEqual(calls, [['showScreen', 3], ['applyOpenRowBindings'], ['bindClicks'], ['bindHoverTexts'], ['bindHoverTriggers']]);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_element : applique la propriété sur l\'élément trouvé, ignore si absent', () => {
|
||||
const el = fakeElement();
|
||||
global.document = { querySelector: (sel) => (sel.includes('"5"') ? el : null) };
|
||||
runActionNode({
|
||||
action_type: 'modifier_element', target_element_id: 5, element_property: 'contenu', element_value: 'Salut',
|
||||
});
|
||||
assert.equal(el.textContent, 'Salut');
|
||||
// Aucune erreur levée quand l'élément n'existe pas (id différent -> querySelector renvoie null).
|
||||
runActionNode({
|
||||
action_type: 'modifier_element', target_element_id: 999, element_property: 'contenu', element_value: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_objet_scene : applique la propriété sur l\'objet trouvé (par target_element_id ou target_object_id)', () => {
|
||||
const el = fakeElement();
|
||||
el.style.width = '10%';
|
||||
global.document = { querySelector: () => el };
|
||||
runActionNode({
|
||||
action_type: 'modifier_objet_scene', target_object_id: 8, element_property: 'contenu', element_value: 'Objet',
|
||||
});
|
||||
assert.equal(el.textContent, 'Objet');
|
||||
});
|
||||
|
||||
test('runActionNode — declencher_evenement : notifie l\'événement personnalisé ciblé', () => {
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'declencher_evenement', target_custom_event_id: 12 });
|
||||
assert.deepEqual(calls, [['dispatchGameEvent', 12]]);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — activer_onglet : montre l\'élément visé, masque ses frères directs, ignore les autres', () => {
|
||||
const elTarget = fakeElement();
|
||||
const elSibling = fakeElement();
|
||||
elSibling.style.display = '';
|
||||
const els = { 1: elTarget, 2: elSibling };
|
||||
global.document = { querySelector: (sel) => els[sel.match(/"(\d+)"/)[1]] };
|
||||
withScreensData({
|
||||
id: 1,
|
||||
elements: [{ id: 1, parent_id: null }, { id: 2, parent_id: null }, { id: 3, parent_id: 99 }],
|
||||
}, () => {
|
||||
runActionNode({ action_type: 'activer_onglet', target_element_id: 1 });
|
||||
});
|
||||
assert.equal(elTarget.style.display, '');
|
||||
assert.equal(elSibling.style.display, 'none');
|
||||
});
|
||||
|
||||
test('runActionNode — attendre : résout après le délai (data_value en secondes), 0 si non numérique', async () => {
|
||||
const t0 = Date.now();
|
||||
await runActionNode({ action_type: 'attendre', data_value: '0.01' });
|
||||
assert.ok(Date.now() - t0 >= 5, 'doit avoir réellement attendu');
|
||||
await runActionNode({ action_type: 'attendre', data_value: 'pas-un-nombre' });
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_donnee : hors ligne délègue à forgeApplyDataActionOffline, en ligne POST vers flowNodeRunData', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'modifier_donnee', id: 11 });
|
||||
assert.deepEqual(calls, [['forgeApplyDataActionOffline', 11], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'modifier_donnee', id: 999999 });
|
||||
assert.equal(calls[0][0], 'fetch');
|
||||
assert.equal(calls[0][1], '/run-data/999999');
|
||||
assert.equal(calls[0][2], 'POST');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_variable : hors ligne délègue à forgeApplyVariableActionOffline, en ligne POST vers flowNodeRunVariable', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'modifier_variable', id: 22 });
|
||||
assert.deepEqual(calls, [['forgeApplyVariableActionOffline', 22], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'modifier_variable', id: 999999 });
|
||||
assert.equal(calls[0][1], '/run-variable/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_score : hors ligne délègue à forgeApplyScoreActionOffline, en ligne POST vers flowNodeRunScore', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'modifier_score', id: 33 });
|
||||
assert.deepEqual(calls, [['forgeApplyScoreActionOffline', 33], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'modifier_score', id: 999999 });
|
||||
assert.equal(calls[0][1], '/run-score/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — definir_statut_partie : hors ligne délègue à forgeApplyStatusActionOffline, en ligne POST vers flowNodeRunStatus', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'definir_statut_partie', id: 44 });
|
||||
assert.deepEqual(calls, [['forgeApplyStatusActionOffline', 44], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'definir_statut_partie', id: 999999 });
|
||||
assert.equal(calls[0][1], '/run-status/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — ajouter_ligne : hors ligne mémorise l\'id renvoyé, en ligne POST puis mémorise json.row_id', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ajouter_ligne', id: 55 });
|
||||
assert.equal(global.window.lastInsertedRowId, 4242);
|
||||
assert.deepEqual(calls, [['forgeApplyAddRowActionOffline', 55], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'ajouter_ligne', id: 999999 });
|
||||
assert.equal(global.window.lastInsertedRowId, 4343);
|
||||
assert.equal(calls[0][1], '/run-add-row/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — jouer_son : joue le fichier audio (fire-and-forget), aucun effet sans data_value', () => {
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'jouer_son', data_value: '/bip.mp3' });
|
||||
assert.deepEqual(calls, [['Audio', '/bip.mp3']]);
|
||||
});
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'jouer_son' });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — action_type inconnu (ou "rien") : ne fait rien, renvoie une Promise résolue', async () => {
|
||||
const result = runActionNode({ action_type: 'rien' });
|
||||
assert.ok(result instanceof Promise);
|
||||
await result;
|
||||
});
|
||||
|
||||
test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => {
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
|
||||
@@ -41,6 +41,11 @@ function fakeEl(style) {
|
||||
return undefined;
|
||||
},
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
|
||||
// Getter (pas une valeur figée à la construction) : recalculé à
|
||||
// chaque lecture, comme le vrai `.dataset`, puisque `_objectId` est
|
||||
// posé APRÈS coup par les tests (voir plus bas) — un `dataset: {}`
|
||||
// gelé au moment de fakeEl() serait toujours vide.
|
||||
get dataset() { return { objectId: this._objectId }; },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function fakeParentEl() {
|
||||
appendChild(child) { children.push(child); },
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/data-for-object-id="(\d+)"/);
|
||||
return m ? children.find((c) => c._forObjectId === m[1]) : undefined;
|
||||
return m ? children.find((c) => c.dataset.forObjectId === m[1]) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,12 +53,24 @@ function fakeEl(parentEl) {
|
||||
};
|
||||
}
|
||||
|
||||
// Un vrai `.dataset` (DOMStringMap) convertit toujours la valeur assignée
|
||||
// en chaîne — reproduit ici via un Proxy, sinon `bubble.dataset.forObjectId
|
||||
// = objectId` avec un objectId numérique (cas réel : obj.id vient de la
|
||||
// base) resterait un number, et ne correspondrait plus jamais à la
|
||||
// capture de regex (toujours une string) dans fakeParentEl.querySelector().
|
||||
function makeDataset() {
|
||||
const store = {};
|
||||
return new Proxy(store, {
|
||||
set(target, key, value) { target[key] = String(value); return true; },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeBubbleEl() {
|
||||
return {
|
||||
className: 'collisionInteractBubble',
|
||||
style: {},
|
||||
textContent: '',
|
||||
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
|
||||
dataset: makeDataset(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,24 @@ const {
|
||||
forgeQuizResultQuit,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
// Un vrai `.dataset` (DOMStringMap) lit/écrit les MÊMES attributs qu'un
|
||||
// `getAttribute('data-foo-bar')`/`setAttribute(...)`, juste sous forme
|
||||
// camelCase (`dataset.fooBar`) — ce Proxy reflète les deux dans le MÊME
|
||||
// objet `attrs` passé en argument, pour que les mocks déjà construits par
|
||||
// clé kebab-case (ex. `{ 'data-timer-mode': 'question' }`, déjà utilisé
|
||||
// par plusieurs tests de ce fichier) continuent de marcher sans y
|
||||
// toucher, tout en exposant `.dataset` comme le vrai DOM (voir
|
||||
// dialogue-box-controller.js après le lot 4 "modernisation JS", S7761).
|
||||
function kebabDataKey(camelProp) {
|
||||
return `data-${camelProp.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`;
|
||||
}
|
||||
function makeDatasetFromAttrs(attrs) {
|
||||
return new Proxy({}, {
|
||||
get(_, prop) { return attrs[kebabDataKey(prop)]; },
|
||||
set(_, prop, value) { attrs[kebabDataKey(prop)] = String(value); return true; },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeWidget(objectId) {
|
||||
const roles = {
|
||||
header: { textContent: '' },
|
||||
@@ -27,8 +45,10 @@ function fakeWidget(objectId) {
|
||||
footer: { innerHTML: '' },
|
||||
};
|
||||
const classes = new Set();
|
||||
const attrs = { 'data-object-id': String(objectId) };
|
||||
return {
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||
dataset: makeDatasetFromAttrs(attrs),
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
@@ -57,7 +77,7 @@ function fakeWidget(objectId) {
|
||||
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
|
||||
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
|
||||
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
|
||||
const screenEl = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) };
|
||||
const screenEl = { dataset: { screenId: '7' } };
|
||||
const attrs = {};
|
||||
const classes = new Set();
|
||||
const bodyChildren = [];
|
||||
@@ -65,7 +85,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
||||
const widget = {
|
||||
parentNode: null,
|
||||
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
dataset: makeDatasetFromAttrs(attrs),
|
||||
classList: { add: (c) => classes.add(c) },
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
@@ -74,7 +94,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
||||
forgeEscapeCameraForFullscreen(widget);
|
||||
assert.equal(bodyChildren[0], widget);
|
||||
assert.equal(widget.parentNode, body);
|
||||
assert.equal(attrs['data-owner-screen-id'], '7');
|
||||
assert.equal(widget.dataset.ownerScreenId, '7');
|
||||
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -288,6 +308,7 @@ function fakeQuizChoiceBtn() {
|
||||
|
||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
attrs = attrs || {};
|
||||
attrs['data-object-id'] = String(objectId);
|
||||
const roles = {
|
||||
header: { textContent: '' },
|
||||
question: { textContent: '' },
|
||||
@@ -312,13 +333,10 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
||||
// un widget "posé sur l'écran courant" est toujours, au départ,
|
||||
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
|
||||
closest: (sel) => (sel === '.playScreen' ? { getAttribute: (a) => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
|
||||
getAttribute: (attr) => {
|
||||
if (attr === 'data-object-id') return String(objectId);
|
||||
if (attr in attrs) return attrs[attr];
|
||||
return null;
|
||||
},
|
||||
closest: (sel) => (sel === '.playScreen' ? { dataset: { screenId: String(window.currentScreenId) } } : null),
|
||||
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
dataset: makeDatasetFromAttrs(attrs),
|
||||
classList: {
|
||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
||||
@@ -600,7 +618,6 @@ test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jama
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
@@ -948,7 +965,7 @@ test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "t
|
||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
dataset: { objectId: '99' },
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
@@ -979,7 +996,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
dataset: { objectId: '99' },
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
|
||||
@@ -34,6 +34,7 @@ function fakeMangaWidget() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
dataset: { objectId: '42' },
|
||||
querySelector(sel) {
|
||||
if (sel === '[data-quiz-role="header"]') return header;
|
||||
if (sel === '[data-manga-role="progress"]') return progress;
|
||||
@@ -139,6 +140,7 @@ function fakeClassiqueWidget() {
|
||||
const progress = { innerHTML: '' };
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
dataset: { objectId: '42' },
|
||||
querySelector(sel) {
|
||||
if (sel === '[data-quiz-role="header"]') return header;
|
||||
if (sel === '[data-classique-role="progress"]') return progress;
|
||||
@@ -182,6 +184,7 @@ function fakeClassiqueWidgetForResult() {
|
||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
dataset: { objectId: '42' },
|
||||
querySelector(sel) {
|
||||
if (sel === '.classiqueContent') return content;
|
||||
if (sel === '[data-classique-role="result"]') return resultZone;
|
||||
|
||||
+363
-264
@@ -20,104 +20,130 @@ const ELEMENT_PROPERTY_CSS = {
|
||||
pos_y: { css: 'top', suffix: '%' },
|
||||
};
|
||||
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme pour
|
||||
// les autres réglages ci-dessous, cible n'importe quel élément — pointer
|
||||
// ceci vers un conteneur remplacerait tout son contenu imbriqué, à éviter.
|
||||
function applyContenuProperty(targetEl, value) {
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
}
|
||||
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans poser
|
||||
// de bordure colorée togglée à la main — utile pour un mentor qui attire
|
||||
// l'attention du joueur vers l'élément à toucher ensuite.
|
||||
function applySurbrillanceProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT de
|
||||
// "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au clic"
|
||||
// éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
function applyDesactiveProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
}
|
||||
|
||||
function applyVisibiliteProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue de
|
||||
// dos" pour chaque sens de déplacement.
|
||||
function applyOrientationProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||
// toujours au même endroit.
|
||||
function applyRelativePositionProperty(targetEl, property, value) {
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = Number.parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = Number.parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
}
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
function applyToggleValueProperty(targetEl, property, spec, parts) {
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
}
|
||||
|
||||
function applyElementProperty(targetEl, property, value) {
|
||||
if (property === 'contenu') {
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||||
// imbriqué, à éviter.
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
applyContenuProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'surbrillance') {
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
||||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
||||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
applySurbrillanceProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'desactive') {
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
||||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
||||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
applyDesactiveProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'visibilite') {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
applyVisibiliteProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'orientation') {
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
|
||||
// de dos" pour chaque sens de déplacement.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
applyOrientationProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||
// toujours au même endroit.
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
applyRelativePositionProperty(targetEl, property, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||
if (!spec || value === undefined || value === null) return;
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
if (typeof value === 'string' && value.includes('||')) {
|
||||
applyToggleValueProperty(targetEl, property, spec, value.split('||'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,8 +164,8 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// risquer un blocage à 0.
|
||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
if (screenData?.scene_width == null) return rawValue;
|
||||
const size = Number.parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||||
@@ -155,206 +181,278 @@ function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
function applyObjectProperty(targetEl, property, value) {
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||
const delta = parseFloat(value);
|
||||
const delta = Number.parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[css]) || 0;
|
||||
const current = Number.parseFloat(targetEl.style[css]) || 0;
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x' || property === 'pos_y') {
|
||||
if (value === undefined || value === null) return;
|
||||
const css = property === 'pos_x' ? 'left' : 'top';
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0)}px`;
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, Number.parseFloat(value) || 0)}px`;
|
||||
return;
|
||||
}
|
||||
applyElementProperty(targetEl, property, value);
|
||||
}
|
||||
|
||||
function runActionNode(node) {
|
||||
if (node.action_type === 'ecran_suivant') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||||
// précise.
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir
|
||||
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
||||
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
||||
// render_scene_object.py), donc le même sélecteur suffit.
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||
// signaler l'événement, jamais choisir un élément — c'est à
|
||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
||||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'attendre') {
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du
|
||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
const seconds = parseFloat(node.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
} if (node.action_type === 'modifier_donnee') {
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
|
||||
// aucun serveur disponible dans le paquet exporté — la même mutation
|
||||
// s'applique directement sur gameData, en mémoire, dans le navigateur.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyDataActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
||||
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
||||
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
|
||||
// sinon, donc toujours transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'modifier_variable') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'modifier_score') {
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyScoreActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'definir_statut_partie') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'ajouter_ligne') {
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
|
||||
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
|
||||
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
|
||||
// "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
} if (node.action_type === 'jouer_son') {
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
|
||||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
||||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try {
|
||||
spriteData = JSON.parse(node.data_value || '{}');
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||
// reste silencieusement absente (spriteData reste {}).
|
||||
console.warn('configuration sprite invalide', e);
|
||||
}
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 'rien' : aucune action volontairement.
|
||||
function runEcranSuivantAction() {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runEcranPrecedentAction() {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runAllerAAction(node) {
|
||||
if (!node.target_screen_id) return Promise.resolve();
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette action
|
||||
// (voir bindClicks dans triggers.js) puis affiche l'écran de détail avec
|
||||
// ses placeholders "champ" résolus contre cette ligne précise.
|
||||
function runOuvrirLigneAction(node) {
|
||||
if (!node.target_screen_id) return Promise.resolve();
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runModifierElementAction(node) {
|
||||
if (!node.target_element_id) return Promise.resolve();
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir applyObjectProperty()
|
||||
// plus bas) plutôt qu'en %. data-element-id est posé sur CHAQUE objet de
|
||||
// scène en plus de data-object-id (voir render_scene_object.py), donc le
|
||||
// même sélecteur suffit.
|
||||
function runModifierObjetSceneAction(node) {
|
||||
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que signaler
|
||||
// l'événement, jamais choisir un élément — c'est à l'écouteur (déclencheur
|
||||
// → condition → action) de décider quoi faire, avec ses propres réglages
|
||||
// habituels.
|
||||
function runDeclencherEvenementAction(node) {
|
||||
if (!node.target_custom_event_id) return Promise.resolve();
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id, y
|
||||
// compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par frère.
|
||||
// screenData.elements contient TOUS les éléments de l'écran (imbriqués
|
||||
// compris, voir list_elements côté serveur), donc ça fonctionne aussi
|
||||
// bien pour des onglets imbriqués dans un même conteneur que pour des
|
||||
// "vues" posées directement sur l'écran.
|
||||
function runActiverOngletAction(node) {
|
||||
if (!node.target_element_id) return Promise.resolve();
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du joueur
|
||||
// (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
function runAttendreAction(node) {
|
||||
const seconds = Number.parseFloat(node.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) : aucun
|
||||
// serveur disponible dans le paquet exporté — la même mutation s'applique
|
||||
// directement sur gameData, en mémoire, dans le navigateur.
|
||||
function runModifierDonneeAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyDataActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur SEULEMENT
|
||||
// si ce nœud vise respectivement CLICKED_ROW_ID ou LAST_INSERTED_ROW_ID
|
||||
// (voir flow_node_run_data.py) — sans effet sinon, donc toujours
|
||||
// transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
function runModifierVariableAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||
function runModifierScoreAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyScoreActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
function runDefinirStatutPartieAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire hors
|
||||
// ligne) ; son id devient window.lastInsertedRowId pour que les nœuds
|
||||
// "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕
|
||||
// Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||
function runAjouterLigneAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la musique
|
||||
// de fond de l'écran (runScreenBackgroundMusic() dans screens.js, qui
|
||||
// elle boucle et s'arrête au changement d'écran). Fire-and-forget : ne
|
||||
// bloque jamais la suite du graphe.
|
||||
function runJouerSonAction(node) {
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runJouerAnimationSpriteAction(node) {
|
||||
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try {
|
||||
spriteData = JSON.parse(node.data_value || '{}');
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||
// reste silencieusement absente (spriteData reste {}).
|
||||
console.warn('configuration sprite invalide', e);
|
||||
}
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Dispatch par action_type — chaque fonction ci-dessus vérifie ELLE-MÊME
|
||||
// ses conditions supplémentaires (ex. target_screen_id présent) et
|
||||
// renvoie une Promise résolue sans effet si elles ne sont pas remplies ;
|
||||
// ce dispatcher n'a donc plus qu'une seule décision (la présence d'un
|
||||
// handler), au lieu d'empiler ici les conditions de chaque branche
|
||||
// (c'était la source du score S3776 restant à 23 après la 1re extraction
|
||||
// en sous-fonctions — un enchaînement de if, même plat, coûte un point
|
||||
// par condition ET par opérateur booléen combiné).
|
||||
// Clés QUOTÉES délibérément (pas juste un style) : plusieurs tests Python
|
||||
// vérifient la présence du littéral quoté ("'jouer_son'" in js, etc.,
|
||||
// voir tests/test_sound.py, tests/test_sprite_animation.py,
|
||||
// tests/test_flow.py) comme preuve que ce action_type est bien implémenté
|
||||
// côté runtime — une clé nue (jouer_son: ...) fait disparaître ce
|
||||
// littéral du bundle JS sans rien changer au comportement, mais casse ces
|
||||
// tests. Gardé quoté pour rester détectable de la même façon.
|
||||
const ACTION_NODE_HANDLERS = {
|
||||
ecran_suivant: runEcranSuivantAction,
|
||||
ecran_precedent: runEcranPrecedentAction,
|
||||
aller_a: runAllerAAction,
|
||||
'ouvrir_ligne': runOuvrirLigneAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||
modifier_element: runModifierElementAction,
|
||||
modifier_objet_scene: runModifierObjetSceneAction,
|
||||
declencher_evenement: runDeclencherEvenementAction,
|
||||
activer_onglet: runActiverOngletAction,
|
||||
attendre: runAttendreAction,
|
||||
modifier_donnee: runModifierDonneeAction,
|
||||
modifier_variable: runModifierVariableAction,
|
||||
modifier_score: runModifierScoreAction,
|
||||
definir_statut_partie: runDefinirStatutPartieAction,
|
||||
ajouter_ligne: runAjouterLigneAction,
|
||||
'jouer_son': runJouerSonAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||
'jouer_animation_sprite': runJouerAnimationSpriteAction, // eslint-disable-line quote-props -- idem
|
||||
};
|
||||
|
||||
function runActionNode(node) {
|
||||
const handler = ACTION_NODE_HANDLERS[node.action_type];
|
||||
// 'rien' (ou tout action_type inconnu) : aucune action volontairement.
|
||||
return handler ? handler(node) : Promise.resolve();
|
||||
}
|
||||
|
||||
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
||||
@@ -382,7 +480,7 @@ const activeSpriteAnimations = new Map();
|
||||
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||||
// les nœuds/clips déjà créés avant ce changement.
|
||||
function resolveSpriteFrames(elementId, spriteData) {
|
||||
const byAnimation = (gameData.personnage_animations || {})[elementId];
|
||||
const byAnimation = gameData.personnage_animations?.[elementId];
|
||||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||||
}
|
||||
@@ -418,7 +516,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
const loop = !!spriteData.loop;
|
||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||
const active = activeSpriteAnimations.get(targetEl);
|
||||
if (active && active.signature === signature) return;
|
||||
if (active?.signature === signature) return;
|
||||
if (active && !active.loop) return;
|
||||
if (active) {
|
||||
clearInterval(active.intervalId);
|
||||
@@ -461,6 +559,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
runSpriteAnimation,
|
||||
stopAllSpriteAnimations,
|
||||
activeSpriteAnimations,
|
||||
applyElementProperty,
|
||||
applyObjectProperty,
|
||||
clampSceneObjectPosition,
|
||||
runActionNode,
|
||||
|
||||
+32
-30
@@ -20,6 +20,21 @@
|
||||
const PLACEHOLDER_OPEN = '{' + '{';
|
||||
const PLACEHOLDER_CLOSE = '}' + '}';
|
||||
|
||||
// Partagée entre applyOpenRowBindings() et _forgeApplyFreshRuntimeData()
|
||||
// (même algorithme, dupliqué à l'identique dans les deux avant ce
|
||||
// refactor S4144) : `byId` explicite en paramètre plutôt que capturé par
|
||||
// fermeture, puisque chaque appelant construit sa PROPRE table (éléments
|
||||
// de l'écran au moment de son propre appel, jamais partagés entre les
|
||||
// deux).
|
||||
function isDescendantOfElement(byId, e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyOpenRowBindings() {
|
||||
if (!window.currentOpenRow) return;
|
||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||
@@ -53,19 +68,11 @@ function applyOpenRowBindings() {
|
||||
const byId = {};
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
function hasUnresolvedPlaceholder(e) {
|
||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
return !!e.rendered_html?.includes(PLACEHOLDER_OPEN);
|
||||
}
|
||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||
const targets = candidates.filter(
|
||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)),
|
||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)),
|
||||
);
|
||||
|
||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||
@@ -92,7 +99,7 @@ function applyOpenRowBindings() {
|
||||
const toSplit = [];
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
||||
if (node.nodeValue.includes(PLACEHOLDER_OPEN)) toSplit.push(node);
|
||||
}
|
||||
toSplit.forEach((textNode) => {
|
||||
const raw = textNode.nodeValue;
|
||||
@@ -125,9 +132,12 @@ function applyOpenRowBindings() {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
||||
const bindKey = `data-bind-attr-${attr.name}`;
|
||||
const template = node.hasAttribute(bindKey)
|
||||
? node.getAttribute(bindKey)
|
||||
: (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
let template = null;
|
||||
if (node.hasAttribute(bindKey)) {
|
||||
template = node.getAttribute(bindKey);
|
||||
} else if (attr.value.includes(PLACEHOLDER_OPEN)) {
|
||||
template = attr.value;
|
||||
}
|
||||
if (template === null) return;
|
||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||
placeholderRe.lastIndex = 0;
|
||||
@@ -227,19 +237,11 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
const byId = {};
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
const hasMarker = (e) => !!(e.rendered_html && (
|
||||
e.rendered_html.indexOf('repeaterItem') !== -1
|
||||
|| e.rendered_html.indexOf('jaugeBar') !== -1
|
||||
|| e.rendered_html.indexOf('visibilityGated') !== -1
|
||||
|| e.rendered_html.indexOf('dataBound') !== -1
|
||||
e.rendered_html.includes('repeaterItem')
|
||||
|| e.rendered_html.includes('jaugeBar')
|
||||
|| e.rendered_html.includes('visibilityGated')
|
||||
|| e.rendered_html.includes('dataBound')
|
||||
));
|
||||
const isDescendantOf = (e, ancestorId) => {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||
// HTML juste après la balise concernée (voir _mark() dans
|
||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
||||
@@ -254,13 +256,13 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||
// de premier niveau existant (l'exemplaire) que parce que son
|
||||
// HTML est imbriqué dans le sien.
|
||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
||||
const COMMENT_MARKERS = new Set(['visibilityGated', 'dataBound']);
|
||||
const findCommentMarkedDescendants = (root) => {
|
||||
const found = [];
|
||||
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
||||
const sib = node.nextSibling;
|
||||
const hasCommentMarker = !!(
|
||||
sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1
|
||||
sib?.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.has(sib.data)
|
||||
);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
@@ -282,7 +284,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
};
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates
|
||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)))
|
||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)))
|
||||
.forEach((el) => {
|
||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||
if (!target) return;
|
||||
@@ -299,7 +301,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
// disparaître ni réapparaître juste parce qu'un texte à
|
||||
// l'intérieur a changé.
|
||||
innerMarked.forEach((innerNode) => {
|
||||
const innerId = innerNode.getAttribute('data-element-id');
|
||||
const innerId = innerNode.dataset.elementId;
|
||||
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
|
||||
@@ -25,10 +25,10 @@ function forgeCollisionRuleStateKey(objectId, ruleIndex) {
|
||||
}
|
||||
|
||||
function forgeElementBoxRect(el, collision) {
|
||||
const left = parseFloat(el.style.left) || 0;
|
||||
const top = parseFloat(el.style.top) || 0;
|
||||
const width = parseFloat(el.style.width) || 0;
|
||||
const height = parseFloat(el.style.height) || 0;
|
||||
const left = Number.parseFloat(el.style.left) || 0;
|
||||
const top = Number.parseFloat(el.style.top) || 0;
|
||||
const width = Number.parseFloat(el.style.width) || 0;
|
||||
const height = Number.parseFloat(el.style.height) || 0;
|
||||
return forgeCollisionRectFromBox({
|
||||
left, top, width, height,
|
||||
}, collision);
|
||||
@@ -86,7 +86,7 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionInteractBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
bubble.dataset.forObjectId = objectId;
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
@@ -112,8 +112,8 @@ function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
|
||||
}
|
||||
|
||||
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
const parentEl = objectEl && objectEl.parentElement;
|
||||
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
const parentEl = objectEl?.parentElement;
|
||||
const bubble = parentEl?.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
bubble.dataset.forObjectId = objectId;
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
@@ -139,7 +139,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
||||
const parentEl = targetEl && targetEl.parentElement;
|
||||
const parentEl = targetEl?.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
const objRect = forgeElementBoxRect(targetEl);
|
||||
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
@@ -153,85 +153,114 @@ function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines —
|
||||
// chaque occurrence de "Déclencher ce dialogue" porte le sien, voir
|
||||
// screens/rendering/collision_rules.py) dans le widget "💬 Boîte de
|
||||
// dialogue" posé sur l'écran (voir static/js/play/dialogue-box-
|
||||
// controller.js) — sans effet si aucun widget n'est posé, comme le reste
|
||||
// de ce moteur. Le nom affiché (en-tête du quiz, xAPI) est celui de
|
||||
// l'objet déclencheur (voir "ℹ️ Informations") — plus de "quête" pour le
|
||||
// porter. mark_completed (optionnel) : marque ce dialogue terminé une
|
||||
// fois épuisé. `action.then` (chaînage, voir maquette validée : "pouvoir
|
||||
// modifier un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur —
|
||||
// transmis à forgeShowDialogueBox, exécuté par dialogue-box-controller.js
|
||||
// une fois le dialogue épuisé.
|
||||
function runDialogueCollisionAction(action, targetEl, obj) {
|
||||
const lines = action.dialogue?.lines || [];
|
||||
const dialogueId = action.dialogue?.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
}
|
||||
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en export,
|
||||
// donc aucun branchement FORGE_OFFLINE nécessaire, même philosophie que
|
||||
// le reste de ce fichier). Instantané : `then` s'exécute tout de suite,
|
||||
// contrairement à un dialogue.
|
||||
function runVariableCollisionAction(action, targetEl, obj) {
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js), même
|
||||
// comparaison qu'un nœud Condition de l'éditeur de logique, sur une
|
||||
// VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet ici
|
||||
// (voir maquette validée : le losange ne teste qu'une variable).
|
||||
function runConditionCollisionAction(action, targetEl, obj) {
|
||||
const isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
const branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
}
|
||||
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas forcément
|
||||
// celui qui porte le déclencheur) — réutilise TEL QUEL applyObjectProperty
|
||||
// (static/js/play/actions.js, équivalent scène 2D d'applyElementProperty),
|
||||
// plutôt que de dupliquer cette logique. Partagée entre "surbrillance" et
|
||||
// "visibilite", qui suivent exactement le même patron.
|
||||
function runSurbrillanceOuVisibiliteCollisionAction(action, targetEl, obj) {
|
||||
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané : `then`
|
||||
// s'exécute tout de suite, sans attendre la fin du son.
|
||||
function runSonCollisionAction(action, targetEl, obj) {
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit qu'un
|
||||
// dialogue ci-dessus.
|
||||
function runVideoCollisionAction(action, targetEl, obj) {
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
}
|
||||
|
||||
function runIndicationCollisionAction(action, targetEl, obj) {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même vocabulaire
|
||||
// que l'action "attendre" du Flow (static/js/play/actions.js), utile ex.
|
||||
// pour laisser un temps de lecture après une narration à l'affichage de
|
||||
// l'écran.
|
||||
function runAttendreCollisionAction(action, targetEl, obj) {
|
||||
const seconds = Number.parseFloat(action.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(() => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
const COLLISION_RULE_ACTION_HANDLERS = {
|
||||
dialogue: runDialogueCollisionAction,
|
||||
variable: runVariableCollisionAction,
|
||||
condition: runConditionCollisionAction,
|
||||
surbrillance: runSurbrillanceOuVisibiliteCollisionAction,
|
||||
visibilite: runSurbrillanceOuVisibiliteCollisionAction,
|
||||
son: runSonCollisionAction,
|
||||
video: runVideoCollisionAction,
|
||||
indication: runIndicationCollisionAction,
|
||||
attendre: runAttendreCollisionAction,
|
||||
};
|
||||
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
// (affichage de bulle + attente de touche).
|
||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
if (action.type === 'dialogue') {
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
||||
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
||||
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
|
||||
// de dialogue" posé sur l'écran (voir static/js/play/
|
||||
// dialogue-box-controller.js) — sans effet si aucun widget n'est
|
||||
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
|
||||
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
||||
// Informations") — plus de "quête" pour le porter. mark_completed
|
||||
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
||||
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||
// controller.js une fois le dialogue épuisé.
|
||||
const lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
const dialogueId = action.dialogue && action.dialogue.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
} else if (action.type === 'variable') {
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en
|
||||
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
|
||||
// philosophie que le reste de ce fichier). Instantané : `then`
|
||||
// s'exécute tout de suite, contrairement à un dialogue.
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'condition') {
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
|
||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
||||
const isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
const branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
||||
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'son') {
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'video') {
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
||||
// qu'un dialogue ci-dessus.
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'attendre') {
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même
|
||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
||||
// narration à l'affichage de l'écran.
|
||||
const seconds = parseFloat(action.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(() => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
const handler = COLLISION_RULE_ACTION_HANDLERS[action.type];
|
||||
if (handler) handler(action, targetEl, obj);
|
||||
}
|
||||
|
||||
function forgeCollisionRuleTick(screenData) {
|
||||
@@ -242,7 +271,7 @@ function forgeCollisionRuleTick(screenData) {
|
||||
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||
@@ -261,7 +290,7 @@ function forgeCollisionRuleTick(screenData) {
|
||||
if (rule.action.type === 'interagir') {
|
||||
if (isActive) {
|
||||
anyInteractActive = true;
|
||||
forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E');
|
||||
forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, player.personnage_commandes?.interagir || 'E');
|
||||
}
|
||||
forgeCollisionRuleActiveState[stateKey] = isActive;
|
||||
return;
|
||||
@@ -280,10 +309,10 @@ function forgeCollisionRuleTick(screenData) {
|
||||
function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
activeCollisionRuleInteractListener = function (ev) {
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
||||
if (!player?.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach((rule, ruleIndex) => {
|
||||
@@ -314,7 +343,7 @@ function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach((rule) => {
|
||||
@@ -347,11 +376,11 @@ function forgeStopCollisionRuleControllers() {
|
||||
// ET au moins un objet avec des règles.
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
if (screenData?.kind !== 'jeu_2d') return;
|
||||
const elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasRules = elements.some((o) => o.collision_rules && o.collision_rules.length);
|
||||
const hasRules = elements.some((o) => o.collision_rules?.length);
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
||||
forgeStartCollisionRuleInteractListener(screenData);
|
||||
|
||||
@@ -43,8 +43,8 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
const e = normalizeBool(expected);
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual); const
|
||||
en = parseFloat(expected);
|
||||
const an = Number.parseFloat(actual); const
|
||||
en = Number.parseFloat(expected);
|
||||
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
||||
if (numeric) {
|
||||
switch (operator) {
|
||||
@@ -87,7 +87,7 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||
// déroulant, voir screen_edit.html).
|
||||
function readVariableValue(name) {
|
||||
const v = (gameData.variables || {})[name];
|
||||
const v = gameData.variables?.[name];
|
||||
return v ? v.value : undefined;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function resolveVariablePath(rawValue, path) {
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
||||
current = m[1] !== undefined ? current[m[1]] : current[Number.parseInt(m[2], 10)];
|
||||
}
|
||||
return current === undefined ? null : current;
|
||||
}
|
||||
@@ -129,13 +129,13 @@ function resolveVariablePath(rawValue, path) {
|
||||
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
|
||||
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
||||
function forgeCollisionRectFromBox(box, collision) {
|
||||
const width = (collision && collision.width) || box.width;
|
||||
const height = (collision && collision.height) || box.height;
|
||||
const offsetX = (collision && collision.offset_x) || 0;
|
||||
const offsetY = (collision && collision.offset_y) || 0;
|
||||
const width = collision?.width || box.width;
|
||||
const height = collision?.height || box.height;
|
||||
const offsetX = collision?.offset_x || 0;
|
||||
const offsetY = collision?.offset_y || 0;
|
||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
||||
const shape = (collision && collision.shape) || 'rectangle';
|
||||
const shape = collision?.shape || 'rectangle';
|
||||
return {
|
||||
left, top, right: left + width, bottom: top + height, width, height, shape,
|
||||
};
|
||||
@@ -156,7 +156,7 @@ function forgeCirclesOverlap(a, b) {
|
||||
br = Math.min(b.width, b.height) / 2;
|
||||
const dx = acx - bcx; const
|
||||
dy = acy - bcy;
|
||||
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
|
||||
return Math.hypot(dx, dy) < (ar + br);
|
||||
}
|
||||
|
||||
// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX
|
||||
@@ -190,13 +190,13 @@ function elementsOverlap(elementIdA, elementIdB) {
|
||||
const objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
||||
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
||||
if (
|
||||
(objA && objA.collision && objA.collision.enabled === false)
|
||||
|| (objB && objB.collision && objB.collision.enabled === false)
|
||||
objA?.collision?.enabled === false
|
||||
|| objB?.collision?.enabled === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
|
||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA?.collision);
|
||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB?.collision);
|
||||
return forgeShapesOverlap(rectA, rectB);
|
||||
}
|
||||
|
||||
@@ -215,9 +215,9 @@ function evaluateConditionClause(clause) {
|
||||
if (source === 'variable') {
|
||||
const varName = clause.variable ?? clause.cond_variable;
|
||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||
const varInfo = (gameData.variables || {})[varName];
|
||||
const varInfo = gameData.variables?.[varName];
|
||||
const actual = resolveVariablePath(readVariableValue(varName), path);
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
const fieldType = varInfo?.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(
|
||||
|
||||
@@ -56,8 +56,8 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// ---------- Boîte de dialogue ----------
|
||||
@@ -141,7 +141,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
||||
function forgeEscapeCameraForFullscreen(widget) {
|
||||
if (widget.parentNode === document.body) return;
|
||||
const screenEl = widget.closest('.playScreen');
|
||||
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
|
||||
if (screenEl) widget.dataset.ownerScreenId = screenEl.dataset.screenId;
|
||||
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||
document.body.appendChild(widget);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
widget.classList.remove('is-active', 'is-fullscreen');
|
||||
const objectId = widget.getAttribute('data-object-id');
|
||||
const objectId = widget.dataset.objectId;
|
||||
forgeStopQuizTimer(objectId);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ const forgeQuizTimerState = {};
|
||||
|
||||
function forgeStopQuizTimer(objectId) {
|
||||
const t = forgeQuizTimerState[objectId];
|
||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
||||
if (t?.intervalId) clearInterval(t.intervalId);
|
||||
delete forgeQuizTimerState[objectId];
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
for (const value of values) value.textContent = window.forgeQuizScore;
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ function forgeUpdateAllScoreWidgets() {
|
||||
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
||||
function forgeUpdateQuizBoxScoreDisplays() {
|
||||
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
|
||||
for (const value of values) value.textContent = `Score : ${window.forgeQuizScore}`;
|
||||
}
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
@@ -341,7 +341,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
||||
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
||||
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
|
||||
const stateKey = widget ? widget.dataset.objectId : (`_sansBoiteDialogue_${dialogueId}`);
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
@@ -400,7 +400,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||
function forgeDialogueBoxAdvance(objectId) {
|
||||
const state = forgeDialogueBoxState[objectId];
|
||||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||||
if (!state || state.lines[state.index]?.type === 'question') return;
|
||||
forgeAdvanceConversation(objectId);
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
const previousState = forgeQuizBoxState[quizObjectId];
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId,
|
||||
@@ -456,14 +456,14 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
||||
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
||||
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
||||
const isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
||||
const isFullscreen = widget.dataset.fullscreen === '1';
|
||||
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
||||
widget.classList.toggle('is-fullscreen', isFullscreen);
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
||||
// imposé, voir screens/rendering/quiz_box_config.py).
|
||||
const timerMode = widget.getAttribute('data-timer-mode');
|
||||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
const timerMode = widget.dataset.timerMode;
|
||||
const timerSeconds = Number.parseInt(widget.dataset.timerSeconds, 10) || 0;
|
||||
if (timerMode === 'question') {
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||
@@ -477,7 +477,17 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
|
||||
// son animation CSS.
|
||||
widget.classList.remove('is-active');
|
||||
void widget.offsetWidth;
|
||||
// Lecture volontairement sans effet apparent : force le navigateur à
|
||||
// calculer la mise en page AVANT le .add() ci-dessous (sans ça, les deux
|
||||
// changements de classe sont fusionnés en une seule passe de rendu et
|
||||
// l'animation ne redémarre jamais). `void` est la forme la plus claire
|
||||
// pour marquer une expression délibérément ignorée (accepté par ESLint,
|
||||
// no-unused-expressions désactivé dans ce projet) — Sonar n'a aucun
|
||||
// idiome qu'il reconnaisse pour ce cas : S3735 (void), S905 (expression
|
||||
// seule sans void) et S1481 (variable jamais lue) se sont succédé sur
|
||||
// les 3 formes essayées, chacune corrigeant la précédente en en créant
|
||||
// une autre. NOSONAR ici plutôt que de continuer, voir CODE_QUALITY.md.
|
||||
void widget.offsetWidth; // NOSONAR S3735 - reflow force, aucune forme alternative ne satisfait Sonar, CODE_QUALITY.md
|
||||
widget.classList.add('is-active');
|
||||
// Effet d'entrée propre au modèle (voir static/js/play/
|
||||
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
||||
@@ -489,42 +499,27 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
||||
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
||||
function forgeQuizTemplateEffectsFor(widget) {
|
||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
const template = widget?.dataset.quizTemplate || 'defaut';
|
||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||
}
|
||||
|
||||
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
|
||||
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
|
||||
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
|
||||
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
|
||||
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
|
||||
// explicite : une mauvaise réponse ne doit jamais bloquer la
|
||||
// progression) — surligne la bonne réponse en vert (et le choix cliqué
|
||||
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
|
||||
// second clic pendant cet affichage est ignoré (`resolved`).
|
||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
state.resolved = true;
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const correct = choiceIndex === state.correctIndex;
|
||||
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
||||
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
||||
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
||||
// "pour tout le quiz", qui doit continuer à tourner sans interruption
|
||||
// d'une question à l'autre.
|
||||
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
|
||||
if (widget) {
|
||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].disabled = true;
|
||||
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
|
||||
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
|
||||
}
|
||||
// Surligne la bonne réponse en vert (et le choix cliqué en rouge s'il
|
||||
// était faux) sur les boutons injectés par forgeShowQuizBox — extrait de
|
||||
// forgeQuizBoxAnswer (lot 4 "modernisation JS", S3776).
|
||||
function markQuizChoiceButtons(widget, state, choiceIndex) {
|
||||
if (!widget) return;
|
||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (const [i, button] of buttons.entries()) {
|
||||
button.disabled = true;
|
||||
if (i === state.correctIndex) button.classList.add('is-correct');
|
||||
else if (i === choiceIndex) button.classList.add('is-wrong');
|
||||
}
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
}
|
||||
|
||||
// Crédite le score/compteurs SI la réponse est bonne, puis déclenche
|
||||
// l'effet visuel du modèle actif (onCorrect/onWrong, voir static/js/play/
|
||||
// quiz-box-templates.js) — extrait de forgeQuizBoxAnswer.
|
||||
function applyQuizAnswerScoreAndEffect(widget, state, correct, fx) {
|
||||
if (correct) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
state.correctCount += 1;
|
||||
@@ -533,6 +528,11 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
} else if (fx.onWrong) {
|
||||
fx.onWrong(widget);
|
||||
}
|
||||
}
|
||||
|
||||
// xAPI + SCORM 2004 (cmi.interactions.n.*, no-op en SCORM 1.2/hors LMS) —
|
||||
// extrait de forgeQuizBoxAnswer.
|
||||
function notifyQuizAnswerTelemetry(state, correct, choiceIndex) {
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||
forgeXapiNotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
@@ -543,8 +543,6 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
correct ? state.rewardAmount : 0,
|
||||
);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
||||
forgeScorm2004NotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
@@ -556,19 +554,50 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
state.correctIndex,
|
||||
);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
// lent ou un utilisateur de lecteur d'écran — l'avancée dépend
|
||||
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
|
||||
// minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox
|
||||
// ci-dessus, TOUJOURS visible — demande explicite, comme la maquette
|
||||
// manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça
|
||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
||||
// choix juste au-dessus).
|
||||
if (widget) {
|
||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul après
|
||||
// un délai fixe (700ms/1400ms), jamais assez pour un lecteur lent ou un
|
||||
// utilisateur de lecteur d'écran — l'avancée dépend maintenant
|
||||
// uniquement d'un clic sur "Continuer →", jamais d'un minuteur. Le
|
||||
// bouton existe déjà (posé désactivé par forgeShowQuizBox, TOUJOURS
|
||||
// visible — demande explicite, comme la maquette manga) : on se contente
|
||||
// ici de l'ACTIVER, jamais de le recréer (ça effacerait le surlignage
|
||||
// vert/rouge qu'on vient de poser sur les choix). Extrait de
|
||||
// forgeQuizBoxAnswer.
|
||||
function enableQuizContinueButton(widget) {
|
||||
if (!widget) return;
|
||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
|
||||
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
|
||||
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
|
||||
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
|
||||
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
|
||||
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
|
||||
// explicite : une mauvaise réponse ne doit jamais bloquer la
|
||||
// progression). Un second clic pendant cet affichage est ignoré
|
||||
// (`resolved`).
|
||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
state.resolved = true;
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const correct = choiceIndex === state.correctIndex;
|
||||
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
||||
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
||||
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
||||
// "pour tout le quiz", qui doit continuer à tourner sans interruption
|
||||
// d'une question à l'autre.
|
||||
if (widget?.dataset.timerMode === 'question') forgeStopQuizTimer(quizObjectId);
|
||||
markQuizChoiceButtons(widget, state, choiceIndex);
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
applyQuizAnswerScoreAndEffect(widget, state, correct, fx);
|
||||
notifyQuizAnswerTelemetry(state, correct, choiceIndex);
|
||||
enableQuizContinueButton(widget);
|
||||
}
|
||||
|
||||
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
|
||||
@@ -595,7 +624,7 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
const { answeredCount } = state;
|
||||
return function () {
|
||||
widget.classList.add('is-active');
|
||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
||||
if (widget.dataset.fullscreen === '1') widget.classList.add('is-fullscreen');
|
||||
completeFx(widget, { correctCount, totalCount: answeredCount });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,3 +111,25 @@ test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', (
|
||||
assert.equal(decoded.clauses.length, 1);
|
||||
assert.equal(decoded.combinator, 'ou');
|
||||
});
|
||||
|
||||
test('forgeDecodeClauses — _filtres_json invalide : avertit en console, retombe sur le legacy sans planter (bug S2486 : exception avalée en silence auparavant)', () => {
|
||||
const attrs = {
|
||||
_filtres_json: '{not valid json',
|
||||
_filtre_champ: 'pv',
|
||||
_filtre_operateur: 'superieur_egal',
|
||||
_filtre_valeur: '10',
|
||||
};
|
||||
const originalWarn = console.warn;
|
||||
const warnCalls = [];
|
||||
console.warn = (...args) => { warnCalls.push(args); };
|
||||
try {
|
||||
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
|
||||
assert.equal(warnCalls.length, 1);
|
||||
assert.match(warnCalls[0][0], /_filtres_json invalide/);
|
||||
// Repli sur l'ancien format à 2 filtres fixes, comme avant ce fix.
|
||||
assert.equal(decoded.clauses.length, 1);
|
||||
assert.equal(decoded.clauses[0].champ, 'pv');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,6 +136,121 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
|
||||
assert.ok(html.includes('jaugeBar'));
|
||||
});
|
||||
|
||||
// Couverture des branches d'afficherValeur/fieldNameNom AVANT le refactor
|
||||
// S3776 (24 -> lot 4 "modernisation JS") — pour garantir que l'extraction
|
||||
// en sous-fonctions ne change aucun comportement.
|
||||
test('jauge — _afficher_valeur affiche la valeur brute du champ dans un label superposé', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _afficher_valeur: '1',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('>42</div>'));
|
||||
});
|
||||
|
||||
test('jauge — _afficher_valeur sans valeur trouvée (champ inconnu) affiche un tiret cadratin, pas "null"/"undefined"', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'champ_qui_n_existe_pas', _min: '0', _max: '100', _afficher_valeur: '1',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('>—</div>'));
|
||||
});
|
||||
|
||||
test('jauge — _row_id sélectionne une ligne précise (pas la plus récente)', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _row_id: '2', _min: '0', _max: '100',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="7"')); // ligne id=2 (Bob, points=7), pas la plus récente (Alice)
|
||||
});
|
||||
|
||||
test('jauge — _champ_nom affiche un label avec le nom, aligné à gauche seulement en position "dessus"+"gauche"', () => {
|
||||
const elGauche = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1',
|
||||
_champ: 'points',
|
||||
_champ_nom: 'nom',
|
||||
_min: '0',
|
||||
_max: '100',
|
||||
_nom_position: 'dessus',
|
||||
_nom_alignement: 'gauche',
|
||||
_nom_gras: '1',
|
||||
_nom_italique: '1',
|
||||
},
|
||||
};
|
||||
const htmlGauche = forgeRenderElementHtml(elGauche, baseGameData(), {}, {});
|
||||
assert.ok(htmlGauche.includes('text-align:left'));
|
||||
assert.ok(htmlGauche.includes('font-weight:700'));
|
||||
assert.ok(htmlGauche.includes('font-style:italic'));
|
||||
assert.ok(htmlGauche.includes('>Alice</div>'));
|
||||
|
||||
const elCote = {
|
||||
id: 11,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _champ_nom: 'nom', _min: '0', _max: '100', _nom_position: 'cote',
|
||||
},
|
||||
};
|
||||
const htmlCote = forgeRenderElementHtml(elCote, baseGameData(), {}, {});
|
||||
assert.ok(htmlCote.includes('text-align:center'));
|
||||
assert.ok(htmlCote.includes('flex-direction:row')); // "cote" bascule aussi la disposition générale
|
||||
});
|
||||
|
||||
test('jauge — _champ_nom sans valeur trouvée (ligne/champ vide) : aucun label de nom généré', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _champ_nom: 'champ_qui_n_existe_pas', _min: '0', _max: '100',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(!html.includes('text-align'));
|
||||
});
|
||||
|
||||
test('jauge — classe CSS supplémentaire (_class_*) fusionnée sur la barre', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _class_extra: 'ma-classe',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.match(html, /class="progress jaugeBar ma-classe"/);
|
||||
});
|
||||
|
||||
test('jauge — min/max absents retombent sur 0/100, valeur non numérique retombe sur min', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: { _definition_id: '1', _champ: 'nom' }, // "nom" = texte, pas un nombre
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="0"')); // valeur non numérique -> repli sur min (0 par défaut)
|
||||
});
|
||||
|
||||
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
const el = {
|
||||
id: 11,
|
||||
|
||||
@@ -18,53 +18,82 @@ function forgePyMod(a, b) {
|
||||
return ((a % b) + b) % b;
|
||||
}
|
||||
|
||||
function computeDefinirNombre(rawValue, round) {
|
||||
const n = Number.parseFloat(rawValue);
|
||||
return Number.isNaN(n) ? 0 : round(n);
|
||||
}
|
||||
|
||||
// Partagée entre "incrementer" et "decrementer" (même calcul, signe
|
||||
// inversé) — extrait de forgeComputeNewValue (lot 4 "modernisation JS", S3776).
|
||||
function computeIncrementerOuDecrementer(operation, current, rawValue, round) {
|
||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(amount)) amount = 0;
|
||||
if (operation === 'decrementer') amount = -amount;
|
||||
return round((current || 0) + amount);
|
||||
}
|
||||
|
||||
function computeMultiplier(current, rawValue, round) {
|
||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(factor)) factor = 1;
|
||||
return round((current || 0) * factor);
|
||||
}
|
||||
|
||||
// Partagée entre "diviser" et "modulo" — extrait de forgeComputeNewValue.
|
||||
function computeDiviserOuModulo(operation, current, rawValue, round) {
|
||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(divisor)) divisor = 0;
|
||||
if (divisor === 0) return current;
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
|
||||
// Partagée entre "minimum" et "maximum" — extrait de forgeComputeNewValue.
|
||||
function computeMinimumOuMaximum(operation, current, rawValue, round) {
|
||||
const bound = Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(bound)) return current;
|
||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
return round(res);
|
||||
}
|
||||
|
||||
function computeAlea(rawValue, isDecimal) {
|
||||
const parts = String(rawValue || '').split(',');
|
||||
let lo = Number.parseFloat(parts[0]); let
|
||||
hi = Number.parseFloat(parts[1]);
|
||||
if (Number.isNaN(lo)) lo = 0;
|
||||
if (Number.isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
const iLo = Math.trunc(lo); const
|
||||
iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
}
|
||||
|
||||
// Dispatch par nom d'opération — chaque fonction ci-dessus reçoit déjà
|
||||
// tout ce dont elle a besoin (current/rawValue/round/isDecimal), ce
|
||||
// dispatcher n'a donc plus qu'une seule décision (présence d'un
|
||||
// handler), au lieu d'empiler ici les conditions de chaque branche (même
|
||||
// méthode que runActionNode, static/js/play/actions.js).
|
||||
const OPERATION_HANDLERS = {
|
||||
definir_texte: (current, rawValue) => rawValue || '',
|
||||
definir_nombre: (current, rawValue, round) => computeDefinirNombre(rawValue, round),
|
||||
definir_bool_vrai: () => 1,
|
||||
definir_bool_faux: () => 0,
|
||||
basculer_bool: (current) => (current ? 0 : 1),
|
||||
incrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('incrementer', current, rawValue, round),
|
||||
decrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('decrementer', current, rawValue, round),
|
||||
multiplier: (current, rawValue, round) => computeMultiplier(current, rawValue, round),
|
||||
diviser: (current, rawValue, round) => computeDiviserOuModulo('diviser', current, rawValue, round),
|
||||
modulo: (current, rawValue, round) => computeDiviserOuModulo('modulo', current, rawValue, round),
|
||||
minimum: (current, rawValue, round) => computeMinimumOuMaximum('minimum', current, rawValue, round),
|
||||
maximum: (current, rawValue, round) => computeMinimumOuMaximum('maximum', current, rawValue, round),
|
||||
alea: (current, rawValue, round, isDecimal) => computeAlea(rawValue, isDecimal),
|
||||
};
|
||||
|
||||
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === 'definir_texte') return rawValue || '';
|
||||
if (operation === 'definir_nombre') {
|
||||
const n = parseFloat(rawValue);
|
||||
return Number.isNaN(n) ? 0 : round(n);
|
||||
}
|
||||
if (operation === 'definir_bool_vrai') return 1;
|
||||
if (operation === 'definir_bool_faux') return 0;
|
||||
if (operation === 'basculer_bool') return current ? 0 : 1;
|
||||
if (operation === 'incrementer' || operation === 'decrementer') {
|
||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(amount)) amount = 0;
|
||||
if (operation === 'decrementer') amount = -amount;
|
||||
return round((current || 0) + amount);
|
||||
}
|
||||
if (operation === 'multiplier') {
|
||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : parseFloat(rawValue);
|
||||
if (Number.isNaN(factor)) factor = 1;
|
||||
return round((current || 0) * factor);
|
||||
}
|
||||
if (operation === 'diviser' || operation === 'modulo') {
|
||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(divisor)) divisor = 0;
|
||||
if (divisor === 0) return current;
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
if (operation === 'minimum' || operation === 'maximum') {
|
||||
const bound = parseFloat(rawValue);
|
||||
if (Number.isNaN(bound)) return current;
|
||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
return round(res);
|
||||
}
|
||||
if (operation === 'alea') {
|
||||
const parts = String(rawValue || '').split(',');
|
||||
let lo = parseFloat(parts[0]); let
|
||||
hi = parseFloat(parts[1]);
|
||||
if (Number.isNaN(lo)) lo = 0;
|
||||
if (Number.isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
const iLo = Math.trunc(lo); const
|
||||
iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
}
|
||||
throw new Error(`opération inconnue : ${operation}`);
|
||||
const handler = OPERATION_HANDLERS[operation];
|
||||
if (!handler) throw new Error(`opération inconnue : ${operation}`);
|
||||
return handler(current, rawValue, round, isDecimal);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
|
||||
@@ -21,7 +21,15 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) clauses = parsed;
|
||||
} catch (e) { clauses = null; }
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// _filtres_json est produit par le rendu serveur, jamais tapé à la
|
||||
// main — un JSON invalide ici trahit presque toujours un bug côté
|
||||
// éditeur/serveur. Comportement inchangé : repli sur l'ancien
|
||||
// format ou aucun filtre, comme avant.
|
||||
console.warn('_filtres_json invalide, filtre ignoré', e);
|
||||
clauses = null;
|
||||
}
|
||||
}
|
||||
if (clauses === null && legacyPrefix) {
|
||||
clauses = [];
|
||||
@@ -48,7 +56,7 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
let current;
|
||||
try {
|
||||
current = rawValue ? JSON.parse(rawValue) : null;
|
||||
} catch (e) {
|
||||
} catch (e) { // NOSONAR S2486 - variable pas au format JSON, cas normal (voir commentaire de la fonction)
|
||||
return null;
|
||||
}
|
||||
let m;
|
||||
@@ -57,9 +65,9 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
const key = m[1]; const
|
||||
index = m[2];
|
||||
try {
|
||||
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
|
||||
current = key !== undefined ? current[key] : current[Number.parseInt(index, 10)];
|
||||
if (current === undefined) return null;
|
||||
} catch (e) {
|
||||
} catch (e) { // NOSONAR S2486 - chemin qui ne correspond pas a la forme actuelle de la variable, cas normal
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +99,7 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
|
||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
if (varMatch) {
|
||||
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
const variable = gameData.variables?.[varMatch[1].trim()];
|
||||
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
||||
}
|
||||
@@ -103,8 +111,8 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
const names = gameData.definition_names || {};
|
||||
for (const defId in names) {
|
||||
if (names[defId] === objectName) {
|
||||
const rows = gameData.data && gameData.data[defId];
|
||||
if (rows && rows.length) {
|
||||
const rows = gameData.data?.[defId];
|
||||
if (rows?.length) {
|
||||
return rows[0][fieldName];
|
||||
}
|
||||
return null;
|
||||
@@ -119,7 +127,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
if (!fieldName) return true;
|
||||
const operator = clause.operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
@@ -127,7 +135,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
|
||||
// Port de filter_rows_by_clauses.
|
||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||
if (!clauses || !clauses.length) return rows;
|
||||
if (!clauses?.length) return rows;
|
||||
if (combinator === 'ou') {
|
||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||
// via forgeSpecialRenderer().
|
||||
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = new Set(['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau']);
|
||||
|
||||
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
||||
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
||||
@@ -66,9 +66,9 @@ const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
|
||||
|
||||
function forgeHtmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
||||
@@ -89,7 +89,7 @@ function forgeAutoId(el) {
|
||||
|
||||
// Port de parent_flex_direction.py.
|
||||
function forgeParentFlexDirection(parentEl) {
|
||||
const style = (parentEl && parentEl.style) || {};
|
||||
const style = parentEl?.style || {};
|
||||
if (style.display !== 'flex') return null;
|
||||
return style['flex-direction'] || 'row';
|
||||
}
|
||||
@@ -99,8 +99,8 @@ function forgeParentFlexDirection(parentEl) {
|
||||
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
|
||||
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
||||
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
const style = { ...el.style || {} };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
||||
const style = { ...el.style };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.has(widgetKey) && !('display' in style)) {
|
||||
style.display = 'flex';
|
||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||
}
|
||||
@@ -150,13 +150,13 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
||||
const rawAttrs = el.attributes || {};
|
||||
const attrs = {};
|
||||
Object.keys(rawAttrs).forEach((k) => {
|
||||
if (k.indexOf('_') === 0) return;
|
||||
if (k.startsWith('_')) return;
|
||||
const v = rawAttrs[k];
|
||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||
});
|
||||
Object.assign(attrs, meta.fixedAttrs || {});
|
||||
const extraClasses = Object.keys(rawAttrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
||||
.filter((k) => k.startsWith('_class_') && rawAttrs[k])
|
||||
.map((k) => rawAttrs[k]);
|
||||
if (extraClasses.length) {
|
||||
const baseClass = attrs.class || '';
|
||||
@@ -170,7 +170,7 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
||||
|
||||
// Port de render_children.py.
|
||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
const kids = childrenMap?.[el.id] || [];
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
||||
}
|
||||
@@ -182,9 +182,9 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
let definitionId = attrs._data_definition_id;
|
||||
if (!definitionId) return null;
|
||||
definitionId = String(definitionId);
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||
if (!fieldsMetaForDef) return null;
|
||||
let rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
let rows = gameData.data?.[definitionId] || [];
|
||||
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
||||
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
if (!rows.length) return null;
|
||||
@@ -206,8 +206,8 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
||||
// d'éventuels enfants copiés en base.
|
||||
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
||||
const templateTree = gameData.element_type_templates?.[String(el.element_type_id)];
|
||||
if (!templateTree?.top.length) return [null, false];
|
||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||
let { top } = templateTree;
|
||||
if (isOverlayOnly) {
|
||||
@@ -255,7 +255,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
const attrsRaw = el.attributes || {};
|
||||
|
||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
||||
const playMode = !!((ctx || {})._forge_play_mode);
|
||||
const playMode = !!ctx?._forge_play_mode;
|
||||
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
||||
}
|
||||
@@ -268,7 +268,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
};
|
||||
|
||||
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
|
||||
if (boundCtx) ctx = { ...ctx, ...boundCtx };
|
||||
|
||||
// meta d'un widget à special_render : fixedAttrs propre (voir
|
||||
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
||||
@@ -319,7 +319,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
||||
}
|
||||
|
||||
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
|
||||
const safeContent = content ? forgeHtmlEscape(content).replaceAll('\n', '<br>') : '';
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,25 @@
|
||||
function forgeHexToRgb(hexColor) {
|
||||
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
||||
if (hex.length !== 6) hex = '5b8cff';
|
||||
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
|
||||
return [
|
||||
Number.parseInt(hex.substr(0, 2), 16),
|
||||
Number.parseInt(hex.substr(2, 2), 16),
|
||||
Number.parseInt(hex.substr(4, 2), 16),
|
||||
];
|
||||
}
|
||||
|
||||
// Extrait de forgeInterpolateColor (lot 4 "modernisation JS", S7721) :
|
||||
// fonction pure (aucune fermeture sur lo/hi/mixed/ratio), pas besoin
|
||||
// d'être redéfinie à chaque appel.
|
||||
function forgeByteToHex(n) {
|
||||
return n.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
const lo = forgeHexToRgb(lowHex); const
|
||||
hi = forgeHexToRgb(highHex);
|
||||
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
||||
const toHex = function (n) { return n.toString(16).padStart(2, '0'); };
|
||||
return `#${mixed.map(toHex).join('')}`;
|
||||
return `#${mixed.map(forgeByteToHex).join('')}`;
|
||||
}
|
||||
|
||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
||||
@@ -23,14 +33,42 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||
if (!definitionId || !fieldName) return null;
|
||||
definitionId = String(definitionId);
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return null;
|
||||
const rows = gameData.data?.[definitionId];
|
||||
if (!rows?.length) return null;
|
||||
let row = null;
|
||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||
if (!row) row = rows[0];
|
||||
return row[fieldName];
|
||||
}
|
||||
|
||||
// Label superposé qui affiche la valeur BRUTE du champ (indépendant du
|
||||
// pourcentage/couleur) — extrait de forgeRenderJauge (lot 4 "modernisation
|
||||
// JS", S3776).
|
||||
function forgeJaugeValueLabelHtml(afficherValeur, rawValue) {
|
||||
if (!afficherValeur) return '';
|
||||
return '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||
}
|
||||
|
||||
// Label du NOM (ex. "Alice"), lu depuis un second champ optionnel — extrait
|
||||
// de forgeRenderJauge.
|
||||
function forgeJaugeNameLabelHtml(gameData, definitionId, rowId, fieldNameNom, nomPosition, nomAlignement, nomStyle) {
|
||||
if (!fieldNameNom) return '';
|
||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue === null || nomValue === undefined || nomValue === '') return '';
|
||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||
const {
|
||||
nomPolice, nomTaille, nomGras, nomItalique,
|
||||
} = nomStyle;
|
||||
return `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||
}
|
||||
|
||||
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const attrs = el.attributes || {};
|
||||
const nomPosition = attrs._nom_position || 'dessus';
|
||||
@@ -47,14 +85,14 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
const rowId = attrs._row_id;
|
||||
const fieldName = (attrs._champ || '').trim();
|
||||
const fieldNameNom = (attrs._champ_nom || '').trim();
|
||||
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||
let minV = Number.parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||
let maxV = Number.parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||
const afficherValeur = attrs._afficher_valeur === '1';
|
||||
|
||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
let value = parseFloat(rawValue);
|
||||
let value = Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(value)) value = minV;
|
||||
|
||||
const span = (maxV - minV) || 1;
|
||||
@@ -62,29 +100,22 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
|
||||
let labelHtml = '';
|
||||
if (afficherValeur) {
|
||||
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||
}
|
||||
|
||||
let nameHtml = '';
|
||||
if (fieldNameNom) {
|
||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
|
||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||
nameHtml = `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||
}
|
||||
}
|
||||
const labelHtml = forgeJaugeValueLabelHtml(afficherValeur, rawValue);
|
||||
const nomStyle = {
|
||||
nomPolice, nomTaille, nomGras, nomItalique,
|
||||
};
|
||||
const nameHtml = forgeJaugeNameLabelHtml(
|
||||
gameData,
|
||||
definitionId,
|
||||
rowId,
|
||||
fieldNameNom,
|
||||
nomPosition,
|
||||
nomAlignement,
|
||||
nomStyle,
|
||||
);
|
||||
|
||||
const extraClasses = Object.keys(attrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
|
||||
.filter((k) => k.startsWith('_class_') && attrs[k])
|
||||
.map((k) => attrs[k]).join(' ');
|
||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
||||
// mais le port reste fidèle pour ne pas diverger inutilement).
|
||||
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
const isPlayMode = !!ctx?._forge_play_mode;
|
||||
const attrsRaw = el.attributes || {};
|
||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||
const radius = attrsRaw._arrondi || '12';
|
||||
|
||||
@@ -10,14 +10,14 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
||||
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
||||
const templateTypeId = attrsRaw._template_type_id;
|
||||
const template = forgeApplyCtx(el.content || '', ctx);
|
||||
const children = (childrenMap && childrenMap[el.id]) || [];
|
||||
const children = childrenMap?.[el.id] || [];
|
||||
|
||||
if (!definitionId) {
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
||||
}
|
||||
definitionId = String(definitionId);
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||
if (!fieldsMetaForDef) {
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
||||
@@ -28,11 +28,11 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
||||
|
||||
let templateTree = null;
|
||||
if (templateTypeId) {
|
||||
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
|
||||
templateTree = gameData.element_type_templates?.[String(templateTypeId)];
|
||||
}
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const rowCtx = { ...ctx || {} };
|
||||
const rowCtx = { ...ctx };
|
||||
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
||||
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
||||
// donc directement la bonne valeur, y compris pour un champ "relation"
|
||||
|
||||
@@ -48,7 +48,7 @@ function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
||||
|
||||
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
const headerRow = ['1', 'true', 'on'].includes(el.attributes?._header_row);
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const rows = content.split('\n').filter((r) => r.trim());
|
||||
@@ -83,7 +83,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
||||
// "icone", posés directement ou nichés dans un élément de jeu
|
||||
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
|
||||
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
|
||||
const iconUrl = gameData.icon_data_uris?.[iconSlug] || (`icons/${iconSlug}.svg`);
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
|
||||
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
|
||||
// description dédié dans l'éditeur) est traitée comme purement
|
||||
@@ -94,7 +94,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
}
|
||||
|
||||
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
const tabs = childrenMap?.[el.id] || [];
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const tabsExtraClasses = attrs.class || '';
|
||||
delete attrs.class;
|
||||
@@ -138,9 +138,9 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
|
||||
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs['data-personnage'] = '1';
|
||||
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
const animations = gameData.personnage_animations?.[String(el.id)];
|
||||
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
const idleFrame = frames && frames.length ? frames[0] : '';
|
||||
const idleFrame = frames?.length ? frames[0] : '';
|
||||
if (idleFrame) attrs.src = idleFrame;
|
||||
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
||||
// décoratif sur le widget "image" générique.
|
||||
|
||||
@@ -21,8 +21,8 @@ let activePersonnageInteractListener = null;
|
||||
const FORGE_PERSONNAGE_FPS = 8;
|
||||
|
||||
function forgePersonnageAnimExists(elementId, animName) {
|
||||
const byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
||||
const byAnim = gameData.personnage_animations?.[elementId];
|
||||
return !!byAnim?.[animName]?.length;
|
||||
}
|
||||
|
||||
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
|
||||
@@ -81,10 +81,10 @@ function forgeUpdateSceneCamera(screenData) {
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||
const px = playerEl
|
||||
? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2
|
||||
? (Number.parseFloat(playerEl.style.left) || 0) + (Number.parseFloat(playerEl.style.width) || 0) / 2
|
||||
: world.width / 2;
|
||||
const py = playerEl
|
||||
? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2
|
||||
? (Number.parseFloat(playerEl.style.top) || 0) + (Number.parseFloat(playerEl.style.height) || 0) / 2
|
||||
: world.height / 2;
|
||||
const offsetX = Math.min(
|
||||
Math.max(px - screenData.scene_width / 2, 0),
|
||||
@@ -125,7 +125,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
||||
// objet dont la collision est explicitement désactivée, ni l'objet qui
|
||||
// se déplace lui-même.
|
||||
function forgeSolidObstacles(screenData, movingId) {
|
||||
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false));
|
||||
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && o.collision?.enabled !== false);
|
||||
}
|
||||
|
||||
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
|
||||
@@ -139,90 +139,118 @@ function forgeSolidObstacles(screenData, movingId) {
|
||||
function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) {
|
||||
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
|
||||
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
||||
for (let i = 0; i < obstacles.length; i++) {
|
||||
const other = obstacles[i];
|
||||
for (const other of obstacles) {
|
||||
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
||||
if (!otherEl) continue;
|
||||
const otherLeft = parseFloat(otherEl.style.left) || 0;
|
||||
const otherTop = parseFloat(otherEl.style.top) || 0;
|
||||
const otherWidth = parseFloat(otherEl.style.width) || 0;
|
||||
const otherHeight = parseFloat(otherEl.style.height) || 0;
|
||||
const otherLeft = Number.parseFloat(otherEl.style.left) || 0;
|
||||
const otherTop = Number.parseFloat(otherEl.style.top) || 0;
|
||||
const otherWidth = Number.parseFloat(otherEl.style.width) || 0;
|
||||
const otherHeight = Number.parseFloat(otherEl.style.height) || 0;
|
||||
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
||||
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Déplacement BRUT tenté (avant collision), selon l'axe autorisé par cmd.axe
|
||||
// — extrait de forgeRunPersonnageControllerTick (lot 4 "modernisation JS", S3776).
|
||||
function forgeComputeMovementDelta(cmd) {
|
||||
let rawDx = 0; let
|
||||
rawDy = 0;
|
||||
if (cmd.axe !== 'vertical') {
|
||||
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||
}
|
||||
if (cmd.axe !== 'horizontal') {
|
||||
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
|
||||
}
|
||||
return { rawDx, rawDy };
|
||||
}
|
||||
|
||||
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un seul
|
||||
// est gêné) — glisser le long d'un mur en diagonale reste possible, plus
|
||||
// naturel qu'un blocage total au moindre contact. Extrait de
|
||||
// forgeRunPersonnageControllerTick.
|
||||
// `box` = {left, top, width, height} (position/taille ACTUELLES, avant
|
||||
// déplacement) ; `rawDelta` = {rawDx, rawDy} (voir forgeComputeMovementDelta)
|
||||
// — regroupés en objets pour rester sous la limite de 7 paramètres (S107),
|
||||
// plutôt que 9 paramètres positionnels distincts.
|
||||
function forgeResolveBlockedMovement(screenData, obj, targetEl, box, rawDelta) {
|
||||
const {
|
||||
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
|
||||
} = box;
|
||||
let { rawDx: dx, rawDy: dy } = rawDelta;
|
||||
if (dx !== 0) {
|
||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
||||
}
|
||||
return { dx, dy };
|
||||
}
|
||||
|
||||
// Anime idle (immobile) ou walk (déplacement effectif) — même fps pour les
|
||||
// deux (bug corrigé par l'utilisateur : idle tournait à 4 i/s contre 8
|
||||
// pour walk, aucune raison réelle de les désynchroniser). Extrait de
|
||||
// forgeRunPersonnageControllerTick.
|
||||
function forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy) {
|
||||
if (dx === 0 && dy === 0) {
|
||||
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Déplace/anime UN personnage joueur pour ce tick — extrait du forEach de
|
||||
// forgeRunPersonnageControllerTick.
|
||||
function forgeRunPersonnagePlayerTick(screenData, obj) {
|
||||
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
||||
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
||||
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
const cmd = obj.personnage_commandes;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
|
||||
const { rawDx, rawDy } = forgeComputeMovementDelta(cmd);
|
||||
const curLeft = Number.parseFloat(targetEl.style.left) || 0;
|
||||
const curTop = Number.parseFloat(targetEl.style.top) || 0;
|
||||
const boxWidth = Number.parseFloat(targetEl.style.width) || 0;
|
||||
const boxHeight = Number.parseFloat(targetEl.style.height) || 0;
|
||||
const box = {
|
||||
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
|
||||
};
|
||||
const { dx, dy } = forgeResolveBlockedMovement(screenData, obj, targetEl, box, { rawDx, rawDy });
|
||||
|
||||
// Se tourne vers la direction TENTÉE même si le déplacement est
|
||||
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
|
||||
// personnage qui reste dos tourné contre ce qui le bloque).
|
||||
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
|
||||
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy);
|
||||
}
|
||||
|
||||
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||
// (triggers.js), pour un déplacement perçu comme continu.
|
||||
function forgeRunPersonnageControllerTick(screenData) {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
||||
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
||||
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
const cmd = obj.personnage_commandes;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
|
||||
let rawDx = 0; let
|
||||
rawDy = 0;
|
||||
if (cmd.axe !== 'vertical') {
|
||||
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||
}
|
||||
if (cmd.axe !== 'horizontal') {
|
||||
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
|
||||
}
|
||||
|
||||
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un
|
||||
// seul est gêné) — glisser le long d'un mur en diagonale reste
|
||||
// possible, plus naturel qu'un blocage total au moindre contact.
|
||||
const curLeft = parseFloat(targetEl.style.left) || 0;
|
||||
const curTop = parseFloat(targetEl.style.top) || 0;
|
||||
const boxWidth = parseFloat(targetEl.style.width) || 0;
|
||||
const boxHeight = parseFloat(targetEl.style.height) || 0;
|
||||
let dx = rawDx; let
|
||||
dy = rawDy;
|
||||
if (dx !== 0) {
|
||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
||||
}
|
||||
// Se tourne vers la direction TENTÉE même si le déplacement est
|
||||
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
|
||||
// personnage qui reste dos tourné contre ce qui le bloque).
|
||||
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
|
||||
|
||||
// Même fps pour idle et walk (bug signalé par l'utilisateur : idle
|
||||
// tournait à 4 i/s contre 8 pour walk, perçue comme "les autres
|
||||
// animations sont lentes à côté de la marche" — aucune raison réelle
|
||||
// de les désynchroniser).
|
||||
if (dx === 0 && dy === 0) {
|
||||
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
(screenData.elements || []).forEach((obj) => forgeRunPersonnagePlayerTick(screenData, obj));
|
||||
}
|
||||
|
||||
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
|
||||
@@ -261,7 +289,7 @@ function forgeStartPersonnageInteractListener(screenData) {
|
||||
// réinitialisé à chaque changement d'écran (voir showScreen()).
|
||||
function forgeStartPersonnageControllers(screenData) {
|
||||
forgeStopPersonnageControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
if (screenData?.kind !== 'jeu_2d') return;
|
||||
const elements = screenData.elements || [];
|
||||
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasFond = elements.some((o) => o.kind === 'fond');
|
||||
|
||||
@@ -15,27 +15,38 @@
|
||||
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
||||
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
||||
// classique (pas de module), lisibles ici telles quelles.
|
||||
// Extrait de forgeQuizProgressOnShow (lot 4 "modernisation JS", S7721) :
|
||||
// fonction pure (aucune fermeture), pas besoin d'être redéfinie à chaque appel.
|
||||
function isQuestionLine(line) { return line?.type === 'question'; }
|
||||
|
||||
// Extrait de forgeQuizProgressOnShow (S3358) : segment "fait"/"courant"/
|
||||
// "à venir" de la barre d'étapes pour la question d'indice `i` (1-based).
|
||||
function forgeQuizProgressBarSegmentClass(i, current) {
|
||||
if (i < current) return 'is-done';
|
||||
if (i === current) return 'is-current';
|
||||
return '';
|
||||
}
|
||||
|
||||
// `progressSelector` : chaque modèle a son propre conteneur de barre
|
||||
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||
if (!widget) return;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
if (!quizState) return;
|
||||
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
if (!dialogueState) return;
|
||||
function isQuestion(line) { return line && line.type === 'question'; }
|
||||
const total = dialogueState.lines.filter(isQuestion).length;
|
||||
const total = dialogueState.lines.filter(isQuestionLine).length;
|
||||
if (total === 0) return;
|
||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestionLine).length;
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = `Question ${current}/${total}`;
|
||||
const progressEl = widget.querySelector(progressSelector);
|
||||
if (progressEl) {
|
||||
const bars = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
bars.push(`<i class="${i < current ? 'is-done' : (i === current ? 'is-current' : '')}"></i>`);
|
||||
bars.push(`<i class="${forgeQuizProgressBarSegmentClass(i, current)}"></i>`);
|
||||
}
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
@@ -79,15 +90,15 @@ function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
const zone = widget.querySelector('[data-classique-role="result"]');
|
||||
if (!content || !zone) return;
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && result.correctCount) || 0;
|
||||
const total = result?.totalCount || 0;
|
||||
const correct = result?.correctCount || 0;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const passed = pct >= threshold;
|
||||
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
const title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
zone.innerHTML = `<div class="classiqueResultRing" style="--pct:${pct}"><span>${pct}%</span></div>`
|
||||
+ `<p class="classiqueResultTitle">${correct} bonne${correct > 1 ? 's' : ''} réponse${correct > 1 ? 's' : ''} sur ${total}</p>`
|
||||
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
||||
@@ -112,13 +123,13 @@ function forgeQuizMangaOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
const zone = widget.querySelector('[data-manga-role="result"]');
|
||||
if (!zone) return;
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && result.correctCount) || 0;
|
||||
const total = result?.totalCount || 0;
|
||||
const correct = result?.correctCount || 0;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const passed = pct >= threshold;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
zone.innerHTML = '<div class="mangaResultCard">'
|
||||
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
||||
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
const rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
rules.forEach((rule) => {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
|
||||
@@ -82,7 +82,7 @@ function runScreenBackgroundMusic(screenId) {
|
||||
currentBackgroundAudio = null;
|
||||
}
|
||||
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
||||
const url = screenData && screenData.background_music_url;
|
||||
const url = screenData?.background_music_url;
|
||||
if (!url) return;
|
||||
currentBackgroundAudio = new Audio(url);
|
||||
currentBackgroundAudio.loop = true;
|
||||
@@ -94,8 +94,8 @@ function runScreenBackgroundMusic(screenId) {
|
||||
// que soit la forme réelle de la fenêtre du joueur.
|
||||
function applyScreenAspect(aspect) {
|
||||
const parts = String(aspect).split('/');
|
||||
const w = parseFloat(parts[0]) || 9; const
|
||||
h = parseFloat(parts[1]) || 16;
|
||||
const w = Number.parseFloat(parts[0]) || 9; const
|
||||
h = Number.parseFloat(parts[1]) || 16;
|
||||
const frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.style.setProperty('--ratio-w', w);
|
||||
@@ -231,12 +231,12 @@ function collectAnimationClips(screenId) {
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
clips = clips.concat(gameData.animations?.[String(sid)] || []);
|
||||
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach((el) => {
|
||||
if (!el.element_type_id) return;
|
||||
const templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
const templateScreenId = gameData.element_types?.[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function bindHoverTexts() {
|
||||
// Espace déclenchent le même comportement que le clic.
|
||||
function bindClicks() {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const elementId = Number.parseInt(elDiv.dataset.elementId, 10);
|
||||
const triggers = findTriggerNodess(elementId, 'clic');
|
||||
if (triggers.length) {
|
||||
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
||||
@@ -133,8 +133,9 @@ function bindClicks() {
|
||||
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
|
||||
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
||||
const rowEl = ev.target.closest('.repeaterItem');
|
||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
window.lastClickedRowId = rowEl ? Number.parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId
|
||||
? Number.parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
@@ -165,7 +166,7 @@ function bindClicks() {
|
||||
function bindHoverTriggers() {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
if (elDiv.dataset.hoverTriggerBound) return;
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const elementId = Number.parseInt(elDiv.dataset.elementId, 10);
|
||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
||||
if (!enterTriggers.length && !leaveTriggers.length) return;
|
||||
|
||||
@@ -39,7 +39,7 @@ const COLLISION_TRIGGER_LABELS = {
|
||||
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||
// pour celles-ci.
|
||||
const COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
||||
const COLLISION_NO_VALUE_OPERATIONS = new Set(['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool']);
|
||||
|
||||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||
@@ -242,7 +242,7 @@ function renderCollisionWizardAttendreStep(onChoose) {
|
||||
|
||||
function collisionWizardAttendreNext() {
|
||||
const raw = document.getElementById('attendreWizSecondes').value;
|
||||
const secondes = String(Math.max(0, parseFloat(raw) || 0));
|
||||
const secondes = String(Math.max(0, Number.parseFloat(raw) || 0));
|
||||
const onChoose = _collisionWizard.onAttendreChosen;
|
||||
_collisionWizard.onAttendreChosen = null;
|
||||
onChoose(secondes);
|
||||
@@ -265,7 +265,7 @@ function renderCollisionWizardObjectTargetStep(onChoose) {
|
||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||
grid.innerHTML = objects.map((o, i) => `<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:${i * 40}ms" onclick="collisionWizardObjectTargetChosen(${o.id})">`
|
||||
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
||||
+ `<span>${(o.name || '').replace(/</g, '<')}</span>`
|
||||
+ `<span>${(o.name || '').replaceAll('<', '<')}</span>`
|
||||
+ '</button>').join('');
|
||||
_collisionWizard.onObjectTargetChosen = onChoose;
|
||||
});
|
||||
@@ -314,7 +314,7 @@ function renderCollisionWizardAssetStep(onChoose, kind) {
|
||||
collisionWizardBodyEl().innerHTML = `<p class="hint mb-2">Aucun fichier ${label} dans "Mes assets" pour l'instant — importe-en un depuis le panneau de gauche (section "Mes assets"), puis recommence.</p>`;
|
||||
return;
|
||||
}
|
||||
const options = assets.map((a) => `<option value="${a.url}">${(a.name || '').replace(/</g, '<')}</option>`).join('');
|
||||
const options = assets.map((a) => `<option value="${a.url}">${(a.name || '').replaceAll('<', '<')}</option>`).join('');
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel fichier (voir "Mes assets") ?</p>'
|
||||
+ `<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">${options}</select>`
|
||||
+ '<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
||||
@@ -364,7 +364,7 @@ function collisionWizardIndicationNext() {
|
||||
const action = {
|
||||
type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte,
|
||||
};
|
||||
if (dureeRaw) action.duree_ms = parseInt(dureeRaw, 10);
|
||||
if (dureeRaw) action.duree_ms = Number.parseInt(dureeRaw, 10);
|
||||
_collisionWizard.pendingIndicationObjectId = null;
|
||||
const onChoose = _collisionWizard.onIndicationChosen;
|
||||
_collisionWizard.onIndicationChosen = null;
|
||||
@@ -418,7 +418,7 @@ function renderCollisionWizardVariableStep(onChoose) {
|
||||
function collisionWizardVariableOperationChanged() {
|
||||
const operation = document.getElementById('varWizOperation').value;
|
||||
const valueInput = document.getElementById('varWizValue');
|
||||
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1 ? '' : 'none';
|
||||
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.has(operation) ? 'none' : '';
|
||||
}
|
||||
|
||||
function collisionWizardVariableNext() {
|
||||
@@ -429,7 +429,7 @@ function collisionWizardVariableNext() {
|
||||
target_variable: document.getElementById('varWizVariable').value,
|
||||
data_operation: operation,
|
||||
};
|
||||
if (COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1) {
|
||||
if (!COLLISION_NO_VALUE_OPERATIONS.has(operation)) {
|
||||
action.data_value = document.getElementById('varWizValue').value;
|
||||
}
|
||||
const onChoose = _collisionWizard.onVariableChosen;
|
||||
@@ -456,9 +456,10 @@ function collisionWizardVariableNext() {
|
||||
// dialogue, "attendre" disparaissait silencieusement.
|
||||
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
||||
headAction = headAction || leafAction;
|
||||
const markCompletedChecked = leafAction.mark_completed ? ' checked' : '';
|
||||
const completionHtml = leafAction.type === 'dialogue'
|
||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">'
|
||||
+ `<input type="checkbox" id="condWizMarkCompleted"${leafAction.mark_completed ? ' checked' : ''}> Marquer ce dialogue comme terminé une fois joué`
|
||||
+ `<input type="checkbox" id="condWizMarkCompleted"${markCompletedChecked}> Marquer ce dialogue comme terminé une fois joué`
|
||||
+ '</label>'
|
||||
: '';
|
||||
const choiceButtons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<button type="button" class="collisionWizardChoice" style="animation-delay:${(i + 1) * 40}ms" onclick="collisionWizardChainDone('${choice}')"><span>${COLLISION_ACTION_LABELS[choice]}</span></button>`).join('');
|
||||
|
||||
@@ -52,7 +52,7 @@ function createGlobalVariable(event) {
|
||||
fetch(`/game/${GAME_SLUG}/variables/new`, {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((json) => {
|
||||
if (!json.row_html) { window.location.reload(); return; }
|
||||
const tbody = document.getElementById('globalVariablesBody');
|
||||
|
||||
@@ -38,7 +38,7 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||||
// posé en base à ce stade (voir screens/scenes/add_scene_object.py),
|
||||
// seul un rechargement peut alors l'afficher.
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!r.ok || contentType.indexOf('application/json') === -1) {
|
||||
if (!r.ok || !contentType.includes('application/json')) {
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ function onCanvasDrop(ev) {
|
||||
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
||||
// qui n'ont rien à "poser".
|
||||
function uploadUserAsset(inputEl, sceneKind) {
|
||||
const file = inputEl.files && inputEl.files[0];
|
||||
const file = inputEl.files?.[0];
|
||||
if (!file) return;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
@@ -193,14 +193,14 @@ function deleteSceneObject(id) {
|
||||
})
|
||||
.then((r) => {
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!r.ok || contentType.indexOf('application/json') === -1) {
|
||||
if (!r.ok || !contentType.includes('application/json')) {
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if (!json || !json.ok) return;
|
||||
if (!json?.ok) return;
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
if (el) el.remove();
|
||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||
@@ -227,8 +227,8 @@ function deleteSceneObject(id) {
|
||||
function applyCameraSize() {
|
||||
const wEl = document.getElementById('scene-camera-w'); const
|
||||
hEl = document.getElementById('scene-camera-h');
|
||||
const width = parseInt(wEl.value, 10) || 1; const
|
||||
height = parseInt(hEl.value, 10) || 1;
|
||||
const width = Number.parseInt(wEl.value, 10) || 1; const
|
||||
height = Number.parseInt(hEl.value, 10) || 1;
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -325,10 +325,10 @@ function onSceneObjectMouseDown(e, id) {
|
||||
startY = e.clientY;
|
||||
let lastClientX = e.clientX; let
|
||||
lastClientY = e.clientY;
|
||||
let curLeft = parseFloat(el.style.left) || 0; let
|
||||
curTop = parseFloat(el.style.top) || 0;
|
||||
const width = parseFloat(el.style.width) || 0; const
|
||||
height = parseFloat(el.style.height) || 0;
|
||||
let curLeft = Number.parseFloat(el.style.left) || 0; let
|
||||
curTop = Number.parseFloat(el.style.top) || 0;
|
||||
const width = Number.parseFloat(el.style.width) || 0; const
|
||||
height = Number.parseFloat(el.style.height) || 0;
|
||||
let moved = false;
|
||||
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
||||
// .floatPanel, z-index:60) restent DÉLIBÉRÉMENT en position:fixed
|
||||
@@ -405,10 +405,10 @@ function onSceneObjectResizeMouseDown(e, id) {
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
let lastClientX = e.clientX; let
|
||||
lastClientY = e.clientY;
|
||||
let curWidth = parseFloat(el.style.width) || 0; let
|
||||
curHeight = parseFloat(el.style.height) || 0;
|
||||
const left = parseFloat(el.style.left) || 0; const
|
||||
top = parseFloat(el.style.top) || 0;
|
||||
let curWidth = Number.parseFloat(el.style.width) || 0; let
|
||||
curHeight = Number.parseFloat(el.style.height) || 0;
|
||||
const left = Number.parseFloat(el.style.left) || 0; const
|
||||
top = Number.parseFloat(el.style.top) || 0;
|
||||
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
||||
// des panneaux flottants le temps du geste, pour rester visible même
|
||||
// en redimensionnant sous l'un d'eux.
|
||||
@@ -459,10 +459,10 @@ function initScenePosFields() {
|
||||
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
||||
function apply() {
|
||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||
const x = parseFloat(xEl.value) || 0; const
|
||||
y = parseFloat(yEl.value) || 0;
|
||||
const w = parseFloat(wEl.value) || 1; const
|
||||
h = parseFloat(hEl.value) || 1;
|
||||
const x = Number.parseFloat(xEl.value) || 0; const
|
||||
y = Number.parseFloat(yEl.value) || 0;
|
||||
const w = Number.parseFloat(wEl.value) || 1; const
|
||||
h = Number.parseFloat(hEl.value) || 1;
|
||||
if (canvasEl) {
|
||||
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
||||
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
||||
@@ -483,7 +483,7 @@ initScenePosFields();
|
||||
function swapSceneObjectCharacter(forgeCharacter) {
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
if (!body) return;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
||||
});
|
||||
@@ -508,7 +508,7 @@ function swapSceneObjectCharacter(forgeCharacter) {
|
||||
function saveSceneObjectRole(role) {
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
if (!body) return;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/role`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -527,19 +527,19 @@ function saveSceneObjectRole(role) {
|
||||
function saveSceneObjectCollision() {
|
||||
const body = document.getElementById('collisionPanelBody');
|
||||
if (!body) return;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||
const enabled = document.getElementById('collision-enabled').checked;
|
||||
const shape = document.getElementById('collision-shape').value;
|
||||
const width = parseFloat(document.getElementById('collision-width').value) || 0;
|
||||
const height = parseFloat(document.getElementById('collision-height').value) || 0;
|
||||
const offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||
const offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||
const width = Number.parseFloat(document.getElementById('collision-width').value) || 0;
|
||||
const height = Number.parseFloat(document.getElementById('collision-height').value) || 0;
|
||||
const offsetX = Number.parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||
const offsetY = Number.parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||
|
||||
const overlay = document.getElementById('collisionBoxOverlay');
|
||||
const objEl = document.getElementById(`el-${objectId}`);
|
||||
if (overlay && objEl) {
|
||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = parseFloat(objEl.style.height) || 0;
|
||||
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||
overlay.style.width = `${width}px`;
|
||||
overlay.style.height = `${height}px`;
|
||||
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
||||
@@ -579,14 +579,14 @@ function onCollisionBoxMouseDown(e, id) {
|
||||
e.preventDefault();
|
||||
const overlay = document.getElementById('collisionBoxOverlay');
|
||||
const objEl = document.getElementById(`el-${id}`);
|
||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = parseFloat(objEl.style.height) || 0;
|
||||
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startLeft = parseFloat(overlay.style.left) || 0; const
|
||||
startTop = parseFloat(overlay.style.top) || 0;
|
||||
const boxWidth = parseFloat(overlay.style.width) || 0; const
|
||||
boxHeight = parseFloat(overlay.style.height) || 0;
|
||||
const startLeft = Number.parseFloat(overlay.style.left) || 0; const
|
||||
startTop = Number.parseFloat(overlay.style.top) || 0;
|
||||
const boxWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||||
boxHeight = Number.parseFloat(overlay.style.height) || 0;
|
||||
|
||||
function onMove(ev) {
|
||||
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
||||
@@ -595,8 +595,8 @@ function onCollisionBoxMouseDown(e, id) {
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
const offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||
const offsetY = (parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||
const offsetX = (Number.parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||
const offsetY = (Number.parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
||||
saveSceneObjectCollision();
|
||||
}
|
||||
@@ -614,14 +614,14 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
||||
e.preventDefault();
|
||||
const overlay = document.getElementById('collisionBoxOverlay');
|
||||
const objEl = document.getElementById(`el-${id}`);
|
||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = parseFloat(objEl.style.height) || 0;
|
||||
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startWidth = parseFloat(overlay.style.width) || 0; const
|
||||
startHeight = parseFloat(overlay.style.height) || 0;
|
||||
const left = parseFloat(overlay.style.left) || 0; const
|
||||
top = parseFloat(overlay.style.top) || 0;
|
||||
const startWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||||
startHeight = Number.parseFloat(overlay.style.height) || 0;
|
||||
const left = Number.parseFloat(overlay.style.left) || 0; const
|
||||
top = Number.parseFloat(overlay.style.top) || 0;
|
||||
|
||||
function onMove(ev) {
|
||||
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
||||
@@ -630,8 +630,8 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
const newWidth = parseFloat(overlay.style.width); const
|
||||
newHeight = parseFloat(overlay.style.height);
|
||||
const newWidth = Number.parseFloat(overlay.style.width); const
|
||||
newHeight = Number.parseFloat(overlay.style.height);
|
||||
const offsetX = left - (objWidth - newWidth) / 2;
|
||||
const offsetY = top - (objHeight - newHeight) / 2;
|
||||
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
||||
@@ -650,12 +650,12 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
||||
function saveSceneObjectCommands() {
|
||||
const body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||
const extra = [];
|
||||
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
||||
const toucheEl = row.querySelector('.personnageKeyCapture');
|
||||
const animEl = row.querySelector('.cmdExtraAnim');
|
||||
if (toucheEl && toucheEl.value && animEl && animEl.value) {
|
||||
if (toucheEl?.value && animEl?.value) {
|
||||
extra.push({ touche: toucheEl.value, animation: animEl.value });
|
||||
}
|
||||
});
|
||||
@@ -713,7 +713,7 @@ function personnageCommandExtraRowEl(objectId, touche, animation) {
|
||||
row.className = 'field is-grouped personnageCommandExtraRow';
|
||||
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
||||
const options = animNames.map((name) => `<option value="${name}"${name === animation ? ' selected' : ''}>${name}</option>`).join('');
|
||||
row.innerHTML = `<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="${(touche || '').replace(/"/g, '"')}"></div>`
|
||||
row.innerHTML = `<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="${(touche || '').replaceAll('"', '"')}"></div>`
|
||||
+ `<div class="control"><div class="select is-small"><select class="cmdExtraAnim">${options}</select></div></div>`
|
||||
+ '<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
||||
const keyInput = row.querySelector('.personnageKeyCapture');
|
||||
@@ -727,7 +727,7 @@ function addPersonnageCommandExtraRow() {
|
||||
const body = document.getElementById('personnageCommandsBody');
|
||||
const container = document.getElementById('personnageCommandsExtra');
|
||||
if (!body || !container) return;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
||||
}
|
||||
|
||||
@@ -767,7 +767,7 @@ function refreshSceneObjectNames() {
|
||||
function forgeHexToRgb(hex) {
|
||||
hex = String(hex || '').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||||
const num = parseInt(hex, 16);
|
||||
const num = Number.parseInt(hex, 16);
|
||||
if (Number.isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
@@ -820,7 +820,7 @@ function saveDialogueBoxStyle(objectId) {
|
||||
};
|
||||
checkDialogueBoxContrast(style);
|
||||
const el = document.getElementById(`el-${objectId}`);
|
||||
const widget = el && el.querySelector('.dialogueBoxWidget');
|
||||
const widget = el?.querySelector('.dialogueBoxWidget');
|
||||
if (widget) {
|
||||
widget.style.fontFamily = style.font_family;
|
||||
widget.style.fontSize = `${style.font_size}px`;
|
||||
@@ -847,10 +847,10 @@ function saveDialogueBoxStyle(objectId) {
|
||||
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
||||
const grid = document.getElementById(gridId);
|
||||
if (grid) {
|
||||
grid.setAttribute('data-selected-template', key);
|
||||
grid.dataset.selectedTemplate = key;
|
||||
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key);
|
||||
for (const tile of tiles) {
|
||||
tile.classList.toggle('is-selected', tile.dataset.template === key);
|
||||
}
|
||||
}
|
||||
saveQuizBoxConfig(objectId);
|
||||
@@ -887,10 +887,10 @@ function saveQuizBoxConfig(objectId) {
|
||||
const grid = document.getElementById(gridId);
|
||||
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
||||
const selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
||||
return grid.getAttribute('data-selected-template') || (selectedTile && selectedTile.getAttribute('data-template'));
|
||||
return grid.dataset.selectedTemplate || selectedTile?.dataset.template;
|
||||
}
|
||||
const fullscreenEl = document.getElementById('quiz-fullscreen');
|
||||
const fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1');
|
||||
const fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.dataset.wasChecked === '1');
|
||||
const config = {
|
||||
fullscreen: fullscreenEl.checked,
|
||||
timer_mode: document.getElementById('quiz-timer-mode').value,
|
||||
@@ -907,7 +907,7 @@ function saveQuizBoxConfig(objectId) {
|
||||
.then((data) => {
|
||||
if (fullscreenChanged) { window.location.reload(); return; }
|
||||
const el = document.getElementById(`el-${objectId}`);
|
||||
const widget = el && el.querySelector('.quizBoxWidget');
|
||||
const widget = el?.querySelector('.quizBoxWidget');
|
||||
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
||||
});
|
||||
}
|
||||
@@ -915,7 +915,7 @@ function saveQuizBoxConfig(objectId) {
|
||||
function initPersonnageCommandsPanel() {
|
||||
const body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach((id) => {
|
||||
bindPersonnageKeyCapture(document.getElementById(id));
|
||||
});
|
||||
|
||||
@@ -11,10 +11,10 @@ function initBuilderPanel() {
|
||||
if (!xEl || !wEl) return;
|
||||
function apply() {
|
||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||
const x = parseFloat(xEl.value) || 0; const
|
||||
y = parseFloat(yEl.value) || 0;
|
||||
const w = parseFloat(wEl.value) || 1; const
|
||||
h = parseFloat(hEl.value) || 1;
|
||||
const x = Number.parseFloat(xEl.value) || 0; const
|
||||
y = Number.parseFloat(yEl.value) || 0;
|
||||
const w = Number.parseFloat(wEl.value) || 1; const
|
||||
h = Number.parseFloat(hEl.value) || 1;
|
||||
if (canvasEl) {
|
||||
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
|
||||
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
|
||||
@@ -109,7 +109,7 @@ function bindJaugeDefinitionSelect() {
|
||||
const champNomSel = document.getElementById('field-champ_nom');
|
||||
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
|
||||
const info = DEFINITIONS_DATA?.[defSel.value] || { rows: [], fields: [] };
|
||||
rowSel.innerHTML = info.rows.length
|
||||
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||||
@@ -149,9 +149,9 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
|
||||
if (!fieldSels.length) return;
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
||||
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${fieldOptionsHtml}`;
|
||||
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
|
||||
});
|
||||
}
|
||||
@@ -200,7 +200,7 @@ function addClauseRow(key) {
|
||||
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
||||
// déjà réglé plus haut dans le panneau).
|
||||
const defSel = document.getElementById('field-data_definition_id');
|
||||
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
|
||||
if (defSel?.value) defSel.dispatchEvent(new Event('change'));
|
||||
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
||||
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
||||
initFilterValuePickers();
|
||||
@@ -227,9 +227,9 @@ function bindClauseListDefinitionSelect(defSelId) {
|
||||
const defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
||||
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${fieldOptionsHtml}`;
|
||||
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
|
||||
const current = sel.value;
|
||||
sel.innerHTML = opts;
|
||||
@@ -266,8 +266,9 @@ function _filterValueDefinitionId(objName) {
|
||||
}
|
||||
|
||||
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
||||
const info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
||||
fieldSel.innerHTML = `<option value="">— champ —</option>${info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
const info = DEFINITIONS_DATA?.[defId] || { fields: [] };
|
||||
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('');
|
||||
fieldSel.innerHTML = `<option value="">— champ —</option>${fieldOptionsHtml}`;
|
||||
}
|
||||
|
||||
function updateFilterValueFromDynamic(wrap) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
function _personnageElementId() {
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
return body ? parseInt(body.dataset.elementId, 10) : null;
|
||||
return body ? Number.parseInt(body.dataset.elementId, 10) : null;
|
||||
}
|
||||
|
||||
function _savePersonnageData(data) {
|
||||
|
||||
@@ -16,7 +16,7 @@ function _stopPersonnageIdlePreview(elementId) {
|
||||
|
||||
function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||
_stopPersonnageIdlePreview(elementId);
|
||||
if (!frames || !frames.length) return;
|
||||
if (!frames?.length) return;
|
||||
targetEl.src = frames[0];
|
||||
if (frames.length === 1) return;
|
||||
let frameIndex = 0;
|
||||
@@ -42,7 +42,7 @@ function restartPersonnageIdlePreview(elementId, animations) {
|
||||
|
||||
function startAllPersonnagePreviews() {
|
||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach((targetEl) => {
|
||||
const elementId = parseInt(targetEl.dataset.elementId, 10);
|
||||
const elementId = Number.parseInt(targetEl.dataset.elementId, 10);
|
||||
const animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
||||
|
||||
@@ -123,7 +123,7 @@ if (!window.__forgeFloatPanelDragBound) {
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
saveFloatPanelState(key, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) });
|
||||
saveFloatPanelState(key, { left: Number.parseFloat(panel.style.left), top: Number.parseFloat(panel.style.top) });
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
@@ -144,7 +144,7 @@ function addIconElement(iconClass) {
|
||||
form.method = 'post';
|
||||
form.action = ELEMENT_ADD_URL;
|
||||
form.innerHTML = '<input type="hidden" name="widget" value="icone">'
|
||||
+ `<input type="hidden" name="icon_class" value="${iconClass.replace(/"/g, '"')}">`;
|
||||
+ `<input type="hidden" name="icon_class" value="${iconClass.replaceAll('"', '"')}">`;
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
@@ -314,7 +314,10 @@ if (!window.__forgeTreeDragBound) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
clearTreeDropIndicators();
|
||||
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
|
||||
let zoneClass = 'dropInside';
|
||||
if (zone === 'before') zoneClass = 'dropBefore';
|
||||
else if (zone === 'after') zoneClass = 'dropAfter';
|
||||
row.classList.add(zoneClass);
|
||||
});
|
||||
|
||||
document.addEventListener('drop', (e) => {
|
||||
@@ -456,10 +459,10 @@ function onElementMouseDown(e, id) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startLeft = parseFloat(el.style.left); const
|
||||
startTop = parseFloat(el.style.top);
|
||||
const width = parseFloat(el.style.width); const
|
||||
height = parseFloat(el.style.height);
|
||||
const startLeft = Number.parseFloat(el.style.left); const
|
||||
startTop = Number.parseFloat(el.style.top);
|
||||
const width = Number.parseFloat(el.style.width); const
|
||||
height = Number.parseFloat(el.style.height);
|
||||
let moved = false;
|
||||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
||||
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
||||
@@ -481,8 +484,8 @@ function onElementMouseDown(e, id) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
if (moved) {
|
||||
const newLeft = parseFloat(el.style.left); const
|
||||
newTop = parseFloat(el.style.top);
|
||||
const newLeft = Number.parseFloat(el.style.left); const
|
||||
newTop = Number.parseFloat(el.style.top);
|
||||
updatePosFields(newLeft, newTop, width, height);
|
||||
saveGeometry(id, newLeft, newTop, width, height);
|
||||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||||
@@ -501,10 +504,10 @@ function onResizeMouseDown(e, id) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startWidth = parseFloat(el.style.width); const
|
||||
startHeight = parseFloat(el.style.height);
|
||||
const left = parseFloat(el.style.left); const
|
||||
top = parseFloat(el.style.top);
|
||||
const startWidth = Number.parseFloat(el.style.width); const
|
||||
startHeight = Number.parseFloat(el.style.height);
|
||||
const left = Number.parseFloat(el.style.left); const
|
||||
top = Number.parseFloat(el.style.top);
|
||||
|
||||
function onMove(ev) {
|
||||
const dx = ev.clientX - startX; const
|
||||
@@ -517,8 +520,8 @@ function onResizeMouseDown(e, id) {
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
const newWidth = parseFloat(el.style.width); const
|
||||
newHeight = parseFloat(el.style.height);
|
||||
const newWidth = Number.parseFloat(el.style.width); const
|
||||
newHeight = Number.parseFloat(el.style.height);
|
||||
updatePosFields(left, top, newWidth, newHeight);
|
||||
saveGeometry(id, left, top, newWidth, newHeight);
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ function triggerBubbleKind(speaker) {
|
||||
// valeur déjà enregistrée.
|
||||
function triggerSpeakerOptions(currentSpeaker) {
|
||||
const options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
||||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||||
if (currentSpeaker && !options.includes(currentSpeaker)) options.push(currentSpeaker);
|
||||
return options;
|
||||
}
|
||||
|
||||
function triggerEscAttr(str) {
|
||||
return (str || '').replace(/"/g, '"');
|
||||
return (str || '').replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
|
||||
@@ -82,11 +82,17 @@ function triggerAudioAssetOptions() {
|
||||
return (typeof USER_ASSETS_OPTIONS !== 'undefined' ? USER_ASSETS_OPTIONS : []).filter((a) => a.kind === 'audio');
|
||||
}
|
||||
|
||||
function triggerAudioOptionHtml(asset, audioUrl) {
|
||||
const selected = asset.url === audioUrl ? ' selected' : '';
|
||||
return `<option value="${asset.url.replaceAll('"', '"')}"${selected}>${asset.name.replaceAll('<', '<')}</option>`;
|
||||
}
|
||||
|
||||
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
||||
const assetOptions = triggerAudioAssetOptions();
|
||||
const selectOptions = `<option value="">Aucune voix off</option>${assetOptions.map((a) => `<option value="${a.url.replace(/"/g, '"')}"${a.url === audioUrl ? ' selected' : ''}>${a.name.replace(/</g, '<')}</option>`).join('')}`;
|
||||
const optionsHtml = assetOptions.map((a) => triggerAudioOptionHtml(a, audioUrl)).join('');
|
||||
const selectOptions = `<option value="">Aucune voix off</option>${optionsHtml}`;
|
||||
const preview = audioUrl
|
||||
? `<audio controls preload="none" src="${audioUrl.replace(/"/g, '"')}" class="questBubbleAudioPreview"></audio>`
|
||||
? `<audio controls preload="none" src="${audioUrl.replaceAll('"', '"')}" class="questBubbleAudioPreview"></audio>`
|
||||
: '';
|
||||
return (
|
||||
'<div class="questBubbleAudioRow">'
|
||||
@@ -101,7 +107,7 @@ function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
||||
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
const kind = triggerBubbleKind(line.speaker);
|
||||
const speakerSelect = triggerSpeakerOptions(line.speaker).map((name) => `<option value="${name.replace(/"/g, '"')}"${name === line.speaker ? ' selected' : ''}>${name === 'Joueur' ? 'Joueur' : name}</option>`).join('');
|
||||
const speakerSelect = triggerSpeakerOptions(line.speaker).map((name) => `<option value="${name.replaceAll('"', '"')}"${name === line.speaker ? ' selected' : ''}>${name === 'Joueur' ? 'Joueur' : name}</option>`).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">'
|
||||
+ `<div class="questBubble questBubble-${kind}">`
|
||||
@@ -112,7 +118,7 @@ function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
+ '<div class="questBubbleBody">'
|
||||
+ '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." '
|
||||
+ 'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" '
|
||||
+ `onchange="triggerInlineUpdateBubbleText('${dialogueId}', ${index}, this.value)">${line.text.replace(/</g, '<')}</textarea>${
|
||||
+ `onchange="triggerInlineUpdateBubbleText('${dialogueId}', ${index}, this.value)">${line.text.replaceAll('<', '<')}</textarea>${
|
||||
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url)
|
||||
}</div>`
|
||||
+ '</div>'
|
||||
@@ -168,7 +174,7 @@ function triggerQuestionBubbleHtml(dialogueId, line, index) {
|
||||
function triggerVariableActionLabel(item) {
|
||||
const opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
|
||||
const valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ` ${item.data_value}` : '';
|
||||
return `${(item.target_variable || '').replace(/</g, '<')} — ${opLabel.replace(/</g, '<')}${valuePart}`;
|
||||
return `${(item.target_variable || '').replaceAll('<', '<')} — ${opLabel.replaceAll('<', '<')}${valuePart}`;
|
||||
}
|
||||
|
||||
// Feuille "Modifier une variable" — un encart compact (même largeur que
|
||||
@@ -201,7 +207,7 @@ const COLLISION_LEAF_PILL_LABELS = {
|
||||
};
|
||||
|
||||
function triggerItemLeafId(item) {
|
||||
return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id;
|
||||
return item.type === 'dialogue' ? item.dialogue?.id : item.id;
|
||||
}
|
||||
|
||||
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
|
||||
@@ -264,7 +270,7 @@ function triggerLeafPillHtml(item, target, targetId, index, total) {
|
||||
}
|
||||
|
||||
function triggerAssetFilename(url) {
|
||||
return ((url || '').split('/').pop() || url || '').replace(/</g, '<');
|
||||
return ((url || '').split('/').pop() || url || '').replaceAll('<', '<');
|
||||
}
|
||||
|
||||
function triggerGenericLeafBoxHtml(title, text) {
|
||||
@@ -295,7 +301,7 @@ function triggerLeafBoxHtml(item) {
|
||||
return triggerGenericLeafBoxHtml('Vidéo', `${triggerAssetFilename(item.asset_url)} — ${mLabel}`);
|
||||
}
|
||||
if (item.type === 'indication') {
|
||||
return triggerGenericLeafBoxHtml('Indication', `Objet #${item.object_id} — ${(item.texte || '').replace(/</g, '<')}`);
|
||||
return triggerGenericLeafBoxHtml('Indication', `Objet #${item.object_id} — ${(item.texte || '').replaceAll('<', '<')}`);
|
||||
}
|
||||
if (item.type === 'attendre') {
|
||||
return triggerGenericLeafBoxHtml('Attendre', `${item.data_value || '0'} seconde(s)`);
|
||||
@@ -328,7 +334,7 @@ function triggerAppendButtonHtml(target, targetId, afterId) {
|
||||
}
|
||||
|
||||
function triggerActionChainHtml(chain, target, targetId) {
|
||||
if (!chain || !chain.length) return '';
|
||||
if (!chain?.length) return '';
|
||||
return chain.map((item, i) => {
|
||||
const leafId = triggerItemLeafId(item);
|
||||
return (
|
||||
@@ -383,14 +389,14 @@ const _triggerCollapsed = {};
|
||||
let _triggerActiveDialogueId = null;
|
||||
|
||||
function _chainLeafIds(chain) {
|
||||
return (chain || []).map((item) => (item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id)).filter(Boolean);
|
||||
return (chain || []).map((item) => (item.type === 'dialogue' ? item.dialogue?.id : item.id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function _triggerLeafIds(trigger) {
|
||||
if (trigger.condition) {
|
||||
const vrai = trigger.condition.si_vrai; const
|
||||
faux = trigger.condition.si_faux;
|
||||
return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain));
|
||||
return _chainLeafIds(vrai?.chain).concat(_chainLeafIds(faux?.chain));
|
||||
}
|
||||
return _chainLeafIds(trigger.chain);
|
||||
}
|
||||
@@ -398,10 +404,11 @@ function _triggerLeafIds(trigger) {
|
||||
function triggerChainHtml(trigger) {
|
||||
const isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
||||
const leafIds = _triggerLeafIds(trigger);
|
||||
const isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
|
||||
const cardOnclick = `triggerClickObjectCard(${trigger.object_id}, ${leafIds[0] ? `'${leafIds[0]}'` : 'null'})`;
|
||||
const isActive = _triggerActiveDialogueId && leafIds.includes(_triggerActiveDialogueId);
|
||||
const firstLeafArg = leafIds[0] ? `'${leafIds[0]}'` : 'null';
|
||||
const cardOnclick = `triggerClickObjectCard(${trigger.object_id}, ${firstLeafArg})`;
|
||||
const card = `<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="${cardOnclick}">${collisionRuleThumbHtml(trigger)
|
||||
}<span class="questTriggerObjectName">${(trigger.object_name || '').replace(/</g, '<')}</span>`
|
||||
}<span class="questTriggerObjectName">${(trigger.object_name || '').replaceAll('<', '<')}</span>`
|
||||
+ '<span class="questTriggerCollapseChevron">▾</span>'
|
||||
+ '</div>';
|
||||
if (isCollapsed) {
|
||||
@@ -465,7 +472,7 @@ function triggerCondArrowHtml(kind, label) {
|
||||
|
||||
function triggerConditionColumnHtml(condition, target, targetId) {
|
||||
const opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
||||
const label = `${(condition.variable || '').replace(/</g, '<')} ${(opLabel || '').replace(/</g, '<')} ${(condition.valeur || '').replace(/</g, '<')}`;
|
||||
const label = `${(condition.variable || '').replaceAll('<', '<')} ${(opLabel || '').replaceAll('<', '<')} ${(condition.valeur || '').replaceAll('<', '<')}`;
|
||||
const branchesHtml = ['si_vrai', 'si_faux'].map((branchKey) => {
|
||||
const kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
||||
const arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||
@@ -520,7 +527,7 @@ function _registerChainDialogues(chain, owner, dialogueIds) {
|
||||
// l'autre.
|
||||
function _clearRegistryEntriesWithKey(key) {
|
||||
Object.keys(_triggerDialogueRegistry).forEach((id) => {
|
||||
if (Object.prototype.hasOwnProperty.call(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
|
||||
if (Object.hasOwn(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -550,8 +557,8 @@ function triggerLoadAll() {
|
||||
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
|
||||
// garde (cliquer sur une autre carte reste possible, voir
|
||||
// triggerClickObjectCard).
|
||||
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
|
||||
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
|
||||
if (!dialogueIds.includes(_triggerActiveDialogueId)) {
|
||||
_triggerActiveDialogueId = dialogueIds.at(-1) || null;
|
||||
}
|
||||
triggerRenderSlot();
|
||||
});
|
||||
@@ -635,11 +642,14 @@ function triggerInlineAddQuestion(dialogueId) {
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// Même effet que triggerInlineUpdateBubbleText ci-dessous (mettre à jour
|
||||
// `.text` d'une ligne puis sauvegarder) — deux noms distincts car
|
||||
// référencés séparément dans le HTML généré (onchange de la question vs.
|
||||
// onchange de la bulle de réplique, voir triggerQuestionBubbleHtml/
|
||||
// triggerDialogueBubbleHtml), mais un seul corps (S4144 : déléguer plutôt
|
||||
// que dupliquer la logique).
|
||||
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
|
||||
const entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].text = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
triggerInlineUpdateBubbleText(dialogueId, index, text);
|
||||
}
|
||||
|
||||
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
|
||||
@@ -651,7 +661,7 @@ function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
|
||||
const entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
const line = entry.dialogueRef.lines[index];
|
||||
const count = Math.max(2, Math.min(4, parseInt(value, 10) || 2));
|
||||
const count = Math.max(2, Math.min(4, Number.parseInt(value, 10) || 2));
|
||||
while (line.choices.length < count) line.choices.push('');
|
||||
while (line.choices.length > count) line.choices.pop();
|
||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||
@@ -676,7 +686,7 @@ function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) {
|
||||
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
|
||||
const entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
|
||||
entry.dialogueRef.lines[index].reward_amount = Math.max(0, Number.parseInt(value, 10) || 0);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -755,7 +765,7 @@ function triggerRenderObjectStep() {
|
||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||
grid.innerHTML = objects.map((o, i) => `<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:${i * 40}ms" onclick="triggerSelectObject(${o.id})">`
|
||||
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
||||
+ `<span>${(o.name || '').replace(/</g, '<')}</span>`
|
||||
+ `<span>${(o.name || '').replaceAll('<', '<')}</span>`
|
||||
+ '</button>').join('');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
var fieldsList = document.getElementById('fieldsList');
|
||||
var template = document.getElementById('fieldRowTemplate');
|
||||
var btnAddField = document.getElementById('btnAddField');
|
||||
var NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal'];
|
||||
var NUMERIC_FIELD_TYPES = new Set(['nombre_entier', 'nombre_decimal']);
|
||||
|
||||
function toggle(target, placeholder, visible) {
|
||||
target.style.display = visible ? '' : 'none';
|
||||
placeholder.style.display = visible ? 'none' : '';
|
||||
}
|
||||
|
||||
function wireRow(row) {
|
||||
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
|
||||
@@ -29,14 +34,9 @@
|
||||
var boundsTargets = row.querySelectorAll('.boundsTarget');
|
||||
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
|
||||
|
||||
function toggle(target, placeholder, visible) {
|
||||
target.style.display = visible ? '' : 'none';
|
||||
placeholder.style.display = visible ? 'none' : '';
|
||||
}
|
||||
|
||||
function syncFieldTypeVisibility() {
|
||||
var isRelation = typeSelect.value === 'relation';
|
||||
var isNumeric = NUMERIC_FIELD_TYPES.includes(typeSelect.value);
|
||||
var isNumeric = NUMERIC_FIELD_TYPES.has(typeSelect.value);
|
||||
toggle(relationTarget, relationPlaceholder, isRelation);
|
||||
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user