Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
66d8eaeae8
commit
ccf836f2c5
+36
-11
@@ -4,12 +4,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
|
|
||||||
# Les jobs "test-*"/"lint-*" tournent sur CHAQUE push (main et dev) :
|
# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et
|
||||||
# jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les outils
|
# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les
|
||||||
# qualité, rien n'empêchait un commit cassé d'atteindre la production (voir
|
# outils qualité, rien n'empêchait un commit cassé d'atteindre la
|
||||||
# l'audit qualité de la Phase 0 du plan). "build-and-push"/"deploy", eux,
|
# production (voir l'audit qualité de la Phase 0 du plan). "build-and-
|
||||||
# restent réservés à main (via le filtre "if" sur gitea.ref) — un push sur
|
# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur
|
||||||
# dev ne doit jamais redéployer la prod, seulement faire tourner tests/lint.
|
# 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
|
# 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
|
# (.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
|
# 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
|
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
|
||||||
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
||||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité.
|
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
|
||||||
#
|
# lui, reste NON-BLOQUANT (continue-on-error) — remis en place le
|
||||||
# SonarQube (job CI + service prod) retiré pour l'instant — voir
|
# 18/09/2026 une fois l'instance prod opérationnelle (voir CODE_QUALITY.md
|
||||||
# CODE_QUALITY.md pour le contexte (accès instance locale/prod bloqué par
|
# pour l'historique du retrait temporaire), le temps que le rapport de
|
||||||
# des soucis d'infra réseau, mis de côté volontairement).
|
# code smells soit entièrement trié.
|
||||||
|
|
||||||
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
||||||
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
||||||
@@ -32,6 +33,8 @@ on:
|
|||||||
# DEPLOY_USER utilisateur SSH sur ce serveur
|
# DEPLOY_USER utilisateur SSH sur ce serveur
|
||||||
# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée 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)
|
# 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
|
# 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
|
# actions du marketplace, pour ne pas dépendre de la disponibilité de
|
||||||
@@ -131,6 +134,28 @@ jobs:
|
|||||||
RUN npm run lint:css
|
RUN npm run lint:css
|
||||||
DOCKERFILE
|
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:
|
build-and-push:
|
||||||
needs: [test-python, test-js, lint-python, lint-js]
|
needs: [test-python, test-js, lint-python, lint-js]
|
||||||
if: gitea.ref == 'refs/heads/main'
|
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`.
|
- `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.
|
- 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
|
### SonarQube
|
||||||
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.
|
- **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
|
## 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
|
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
|
## 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: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/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/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 |
|
| `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 |
|
| 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/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: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/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/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: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/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"
|
### 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é
|
## 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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
// dessus plutôt que de rejouer un script inline.
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const newWidth = parseFloat(canvas.style.width);
|
const newWidth = Number.parseFloat(canvas.style.width);
|
||||||
const newHeight = parseFloat(canvas.style.height);
|
const newHeight = Number.parseFloat(canvas.style.height);
|
||||||
if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
||||||
if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ function iaChatLoadMessages(conversationId) {
|
|||||||
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`)
|
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((messages) => {
|
.then((messages) => {
|
||||||
if (!messages || !messages.length) {
|
if (!messages?.length) {
|
||||||
log.innerHTML = iaChatEmptyStateHtml();
|
log.innerHTML = iaChatEmptyStateHtml();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,32 @@ global.window = global.window || {};
|
|||||||
global.gameData = global.gameData || {};
|
global.gameData = global.gameData || {};
|
||||||
const {
|
const {
|
||||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||||
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
applyElementProperty, applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||||
} = require('../actions.js');
|
} = require('../actions.js');
|
||||||
|
|
||||||
function fakeImg() {
|
function fakeImg() {
|
||||||
return { src: '' };
|
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', () => {
|
test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => {
|
||||||
const el = fakeImg();
|
const el = fakeImg();
|
||||||
const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true };
|
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
|
// maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne
|
||||||
// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent
|
// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent
|
||||||
// désormais à [0, scene_width/height - sa propre taille].
|
// 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) {
|
function withScreensData(screenData, fn) {
|
||||||
const previousScreens = global.screensData;
|
const previousScreens = global.screensData;
|
||||||
const previousId = global.window.currentScreenId;
|
const previousId = global.window.currentScreenId;
|
||||||
@@ -97,6 +223,276 @@ function withScreensData(screenData, fn) {
|
|||||||
try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; }
|
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', () => {
|
test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => {
|
||||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
||||||
const el = { style: { width: '40px', height: '40px' } };
|
const el = { style: { width: '40px', height: '40px' } };
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ function fakeEl(style) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
|
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); },
|
appendChild(child) { children.push(child); },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
const m = sel.match(/data-for-object-id="(\d+)"/);
|
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() {
|
function fakeBubbleEl() {
|
||||||
return {
|
return {
|
||||||
className: 'collisionInteractBubble',
|
className: 'collisionInteractBubble',
|
||||||
style: {},
|
style: {},
|
||||||
textContent: '',
|
textContent: '',
|
||||||
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
|
dataset: makeDataset(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ const {
|
|||||||
forgeQuizResultQuit,
|
forgeQuizResultQuit,
|
||||||
} = require('../dialogue-box-controller.js');
|
} = 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) {
|
function fakeWidget(objectId) {
|
||||||
const roles = {
|
const roles = {
|
||||||
header: { textContent: '' },
|
header: { textContent: '' },
|
||||||
@@ -27,8 +45,10 @@ function fakeWidget(objectId) {
|
|||||||
footer: { innerHTML: '' },
|
footer: { innerHTML: '' },
|
||||||
};
|
};
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
|
const attrs = { 'data-object-id': String(objectId) };
|
||||||
return {
|
return {
|
||||||
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
|
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||||
|
dataset: makeDatasetFromAttrs(attrs),
|
||||||
classList: {
|
classList: {
|
||||||
add: (c) => classes.add(c),
|
add: (c) => classes.add(c),
|
||||||
remove: (c) => classes.delete(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
|
// 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.
|
// 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', () => {
|
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 attrs = {};
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
const bodyChildren = [];
|
const bodyChildren = [];
|
||||||
@@ -65,7 +85,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
|||||||
const widget = {
|
const widget = {
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
||||||
setAttribute: (k, v) => { attrs[k] = v; },
|
dataset: makeDatasetFromAttrs(attrs),
|
||||||
classList: { add: (c) => classes.add(c) },
|
classList: { add: (c) => classes.add(c) },
|
||||||
};
|
};
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
@@ -74,7 +94,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
|||||||
forgeEscapeCameraForFullscreen(widget);
|
forgeEscapeCameraForFullscreen(widget);
|
||||||
assert.equal(bodyChildren[0], widget);
|
assert.equal(bodyChildren[0], widget);
|
||||||
assert.equal(widget.parentNode, body);
|
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);
|
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
|
||||||
} finally { global.document = previousDoc; }
|
} finally { global.document = previousDoc; }
|
||||||
});
|
});
|
||||||
@@ -288,6 +308,7 @@ function fakeQuizChoiceBtn() {
|
|||||||
|
|
||||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||||
attrs = attrs || {};
|
attrs = attrs || {};
|
||||||
|
attrs['data-object-id'] = String(objectId);
|
||||||
const roles = {
|
const roles = {
|
||||||
header: { textContent: '' },
|
header: { textContent: '' },
|
||||||
question: { textContent: '' },
|
question: { textContent: '' },
|
||||||
@@ -312,13 +333,10 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
|||||||
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
||||||
// un widget "posé sur l'écran courant" est toujours, au départ,
|
// 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.
|
// 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),
|
closest: (sel) => (sel === '.playScreen' ? { dataset: { screenId: String(window.currentScreenId) } } : null),
|
||||||
getAttribute: (attr) => {
|
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||||
if (attr === 'data-object-id') return String(objectId);
|
|
||||||
if (attr in attrs) return attrs[attr];
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
setAttribute: (k, v) => { attrs[k] = v; },
|
setAttribute: (k, v) => { attrs[k] = v; },
|
||||||
|
dataset: makeDatasetFromAttrs(attrs),
|
||||||
classList: {
|
classList: {
|
||||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
// 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', () => {
|
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);
|
const dialogueWidget = fakeWidget(42);
|
||||||
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
|
|
||||||
const quizWidget = fakeQuizWidget(99, 2);
|
const quizWidget = fakeQuizWidget(99, 2);
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
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é', () => {
|
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||||
const dialogueWidget = fakeWidget(42);
|
const dialogueWidget = fakeWidget(42);
|
||||||
const quizWidget = {
|
const quizWidget = {
|
||||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
dataset: { objectId: '99' },
|
||||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||||
querySelectorAll: () => [],
|
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)', () => {
|
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
|
||||||
const dialogueWidget = fakeWidget(42);
|
const dialogueWidget = fakeWidget(42);
|
||||||
const quizWidget = {
|
const quizWidget = {
|
||||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
dataset: { objectId: '99' },
|
||||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function fakeMangaWidget() {
|
|||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
return {
|
return {
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||||
|
dataset: { objectId: '42' },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
if (sel === '[data-quiz-role="header"]') return header;
|
if (sel === '[data-quiz-role="header"]') return header;
|
||||||
if (sel === '[data-manga-role="progress"]') return progress;
|
if (sel === '[data-manga-role="progress"]') return progress;
|
||||||
@@ -139,6 +140,7 @@ function fakeClassiqueWidget() {
|
|||||||
const progress = { innerHTML: '' };
|
const progress = { innerHTML: '' };
|
||||||
return {
|
return {
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||||
|
dataset: { objectId: '42' },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
if (sel === '[data-quiz-role="header"]') return header;
|
if (sel === '[data-quiz-role="header"]') return header;
|
||||||
if (sel === '[data-classique-role="progress"]') return progress;
|
if (sel === '[data-classique-role="progress"]') return progress;
|
||||||
@@ -182,6 +184,7 @@ function fakeClassiqueWidgetForResult() {
|
|||||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||||
return {
|
return {
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||||
|
dataset: { objectId: '42' },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
if (sel === '.classiqueContent') return content;
|
if (sel === '.classiqueContent') return content;
|
||||||
if (sel === '[data-classique-role="result"]') return resultZone;
|
if (sel === '[data-classique-role="result"]') return resultZone;
|
||||||
|
|||||||
+363
-264
@@ -20,104 +20,130 @@ const ELEMENT_PROPERTY_CSS = {
|
|||||||
pos_y: { css: 'top', suffix: '%' },
|
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) {
|
function applyElementProperty(targetEl, property, value) {
|
||||||
if (property === 'contenu') {
|
if (property === 'contenu') {
|
||||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
applyContenuProperty(targetEl, value);
|
||||||
// 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;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'surbrillance') {
|
if (property === 'surbrillance') {
|
||||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
applySurbrillanceProperty(targetEl, value);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'desactive') {
|
if (property === 'desactive') {
|
||||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
applyDesactiveProperty(targetEl, value);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'visibilite') {
|
if (property === 'visibilite') {
|
||||||
if (value === 'toggle') {
|
applyVisibiliteProperty(targetEl, value);
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'orientation') {
|
if (property === 'orientation') {
|
||||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
applyOrientationProperty(targetEl, value);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
applyRelativePositionProperty(targetEl, property, value);
|
||||||
// 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;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||||
if (!spec || value === undefined || value === null) return;
|
if (!spec || value === undefined || value === null) return;
|
||||||
|
|
||||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
if (typeof value === 'string' && value.includes('||')) {
|
||||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
applyToggleValueProperty(targetEl, property, spec, value.split('||'));
|
||||||
// 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';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +164,8 @@ function applyElementProperty(targetEl, property, value) {
|
|||||||
// risquer un blocage à 0.
|
// risquer un blocage à 0.
|
||||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
if (screenData?.scene_width == null) return rawValue;
|
||||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
const size = Number.parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
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 boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||||||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||||||
@@ -155,206 +181,278 @@ function clampSceneObjectPosition(targetEl, css, rawValue) {
|
|||||||
function applyObjectProperty(targetEl, property, value) {
|
function applyObjectProperty(targetEl, property, value) {
|
||||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||||
const delta = parseFloat(value);
|
const delta = Number.parseFloat(value);
|
||||||
if (Number.isNaN(delta)) return;
|
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`;
|
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'pos_x' || property === 'pos_y') {
|
if (property === 'pos_x' || property === 'pos_y') {
|
||||||
if (value === undefined || value === null) return;
|
if (value === undefined || value === null) return;
|
||||||
const css = property === 'pos_x' ? 'left' : 'top';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
applyElementProperty(targetEl, property, value);
|
applyElementProperty(targetEl, property, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runActionNode(node) {
|
function runEcranSuivantAction() {
|
||||||
if (node.action_type === 'ecran_suivant') {
|
const idx = screenIndexById(window.currentScreenId);
|
||||||
const idx = screenIndexById(window.currentScreenId);
|
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||||
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.
|
|
||||||
return Promise.resolve();
|
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) ----------
|
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||||||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||||||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
// 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
|
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||||||
// les nœuds/clips déjà créés avant ce changement.
|
// les nœuds/clips déjà créés avant ce changement.
|
||||||
function resolveSpriteFrames(elementId, spriteData) {
|
function resolveSpriteFrames(elementId, spriteData) {
|
||||||
const byAnimation = (gameData.personnage_animations || {})[elementId];
|
const byAnimation = gameData.personnage_animations?.[elementId];
|
||||||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||||||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||||||
}
|
}
|
||||||
@@ -418,7 +516,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
|||||||
const loop = !!spriteData.loop;
|
const loop = !!spriteData.loop;
|
||||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||||
const active = activeSpriteAnimations.get(targetEl);
|
const active = activeSpriteAnimations.get(targetEl);
|
||||||
if (active && active.signature === signature) return;
|
if (active?.signature === signature) return;
|
||||||
if (active && !active.loop) return;
|
if (active && !active.loop) return;
|
||||||
if (active) {
|
if (active) {
|
||||||
clearInterval(active.intervalId);
|
clearInterval(active.intervalId);
|
||||||
@@ -461,6 +559,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
runSpriteAnimation,
|
runSpriteAnimation,
|
||||||
stopAllSpriteAnimations,
|
stopAllSpriteAnimations,
|
||||||
activeSpriteAnimations,
|
activeSpriteAnimations,
|
||||||
|
applyElementProperty,
|
||||||
applyObjectProperty,
|
applyObjectProperty,
|
||||||
clampSceneObjectPosition,
|
clampSceneObjectPosition,
|
||||||
runActionNode,
|
runActionNode,
|
||||||
|
|||||||
+32
-30
@@ -20,6 +20,21 @@
|
|||||||
const PLACEHOLDER_OPEN = '{' + '{';
|
const PLACEHOLDER_OPEN = '{' + '{';
|
||||||
const PLACEHOLDER_CLOSE = '}' + '}';
|
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() {
|
function applyOpenRowBindings() {
|
||||||
if (!window.currentOpenRow) return;
|
if (!window.currentOpenRow) return;
|
||||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||||
@@ -53,19 +68,11 @@ function applyOpenRowBindings() {
|
|||||||
const byId = {};
|
const byId = {};
|
||||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||||
function hasUnresolvedPlaceholder(e) {
|
function hasUnresolvedPlaceholder(e) {
|
||||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
return !!e.rendered_html?.includes(PLACEHOLDER_OPEN);
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||||
const targets = candidates.filter(
|
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
|
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||||
@@ -92,7 +99,7 @@ function applyOpenRowBindings() {
|
|||||||
const toSplit = [];
|
const toSplit = [];
|
||||||
let node;
|
let node;
|
||||||
while ((node = walker.nextNode())) {
|
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) => {
|
toSplit.forEach((textNode) => {
|
||||||
const raw = textNode.nodeValue;
|
const raw = textNode.nodeValue;
|
||||||
@@ -125,9 +132,12 @@ function applyOpenRowBindings() {
|
|||||||
Array.from(node.attributes).forEach((attr) => {
|
Array.from(node.attributes).forEach((attr) => {
|
||||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
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 bindKey = `data-bind-attr-${attr.name}`;
|
||||||
const template = node.hasAttribute(bindKey)
|
let template = null;
|
||||||
? node.getAttribute(bindKey)
|
if (node.hasAttribute(bindKey)) {
|
||||||
: (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
template = node.getAttribute(bindKey);
|
||||||
|
} else if (attr.value.includes(PLACEHOLDER_OPEN)) {
|
||||||
|
template = attr.value;
|
||||||
|
}
|
||||||
if (template === null) return;
|
if (template === null) return;
|
||||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||||
placeholderRe.lastIndex = 0;
|
placeholderRe.lastIndex = 0;
|
||||||
@@ -227,19 +237,11 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
const byId = {};
|
const byId = {};
|
||||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||||
const hasMarker = (e) => !!(e.rendered_html && (
|
const hasMarker = (e) => !!(e.rendered_html && (
|
||||||
e.rendered_html.indexOf('repeaterItem') !== -1
|
e.rendered_html.includes('repeaterItem')
|
||||||
|| e.rendered_html.indexOf('jaugeBar') !== -1
|
|| e.rendered_html.includes('jaugeBar')
|
||||||
|| e.rendered_html.indexOf('visibilityGated') !== -1
|
|| e.rendered_html.includes('visibilityGated')
|
||||||
|| e.rendered_html.indexOf('dataBound') !== -1
|
|| 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
|
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||||
// HTML juste après la balise concernée (voir _mark() dans
|
// HTML juste après la balise concernée (voir _mark() dans
|
||||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
// 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
|
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||||
// de premier niveau existant (l'exemplaire) que parce que son
|
// de premier niveau existant (l'exemplaire) que parce que son
|
||||||
// HTML est imbriqué dans le sien.
|
// HTML est imbriqué dans le sien.
|
||||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
const COMMENT_MARKERS = new Set(['visibilityGated', 'dataBound']);
|
||||||
const findCommentMarkedDescendants = (root) => {
|
const findCommentMarkedDescendants = (root) => {
|
||||||
const found = [];
|
const found = [];
|
||||||
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
||||||
const sib = node.nextSibling;
|
const sib = node.nextSibling;
|
||||||
const hasCommentMarker = !!(
|
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
|
// Un élément actuellement CACHÉ par une condition de
|
||||||
// visibilité (voir render_element_html.py) n'a pas de
|
// visibilité (voir render_element_html.py) n'a pas de
|
||||||
@@ -282,7 +284,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
};
|
};
|
||||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||||
markedCandidates
|
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) => {
|
.forEach((el) => {
|
||||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -299,7 +301,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
// disparaître ni réapparaître juste parce qu'un texte à
|
// disparaître ni réapparaître juste parce qu'un texte à
|
||||||
// l'intérieur a changé.
|
// l'intérieur a changé.
|
||||||
innerMarked.forEach((innerNode) => {
|
innerMarked.forEach((innerNode) => {
|
||||||
const innerId = innerNode.getAttribute('data-element-id');
|
const innerId = innerNode.dataset.elementId;
|
||||||
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
||||||
if (liveInner) liveInner.replaceWith(innerNode);
|
if (liveInner) liveInner.replaceWith(innerNode);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ function forgeCollisionRuleStateKey(objectId, ruleIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgeElementBoxRect(el, collision) {
|
function forgeElementBoxRect(el, collision) {
|
||||||
const left = parseFloat(el.style.left) || 0;
|
const left = Number.parseFloat(el.style.left) || 0;
|
||||||
const top = parseFloat(el.style.top) || 0;
|
const top = Number.parseFloat(el.style.top) || 0;
|
||||||
const width = parseFloat(el.style.width) || 0;
|
const width = Number.parseFloat(el.style.width) || 0;
|
||||||
const height = parseFloat(el.style.height) || 0;
|
const height = Number.parseFloat(el.style.height) || 0;
|
||||||
return forgeCollisionRectFromBox({
|
return forgeCollisionRectFromBox({
|
||||||
left, top, width, height,
|
left, top, width, height,
|
||||||
}, collision);
|
}, collision);
|
||||||
@@ -86,7 +86,7 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
|||||||
if (!bubble) {
|
if (!bubble) {
|
||||||
bubble = document.createElement('div');
|
bubble = document.createElement('div');
|
||||||
bubble.className = 'collisionInteractBubble';
|
bubble.className = 'collisionInteractBubble';
|
||||||
bubble.setAttribute('data-for-object-id', objectId);
|
bubble.dataset.forObjectId = objectId;
|
||||||
parentEl.appendChild(bubble);
|
parentEl.appendChild(bubble);
|
||||||
}
|
}
|
||||||
return bubble;
|
return bubble;
|
||||||
@@ -112,8 +112,8 @@ function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||||
const parentEl = objectEl && objectEl.parentElement;
|
const parentEl = objectEl?.parentElement;
|
||||||
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
const bubble = parentEl?.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||||
if (bubble) bubble.style.display = 'none';
|
if (bubble) bubble.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
|||||||
if (!bubble) {
|
if (!bubble) {
|
||||||
bubble = document.createElement('div');
|
bubble = document.createElement('div');
|
||||||
bubble.className = 'collisionIndicationBubble';
|
bubble.className = 'collisionIndicationBubble';
|
||||||
bubble.setAttribute('data-for-object-id', objectId);
|
bubble.dataset.forObjectId = objectId;
|
||||||
parentEl.appendChild(bubble);
|
parentEl.appendChild(bubble);
|
||||||
}
|
}
|
||||||
return bubble;
|
return bubble;
|
||||||
@@ -139,7 +139,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
|||||||
|
|
||||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||||
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
||||||
const parentEl = targetEl && targetEl.parentElement;
|
const parentEl = targetEl?.parentElement;
|
||||||
if (!targetEl || !parentEl || !texte) return;
|
if (!targetEl || !parentEl || !texte) return;
|
||||||
const objRect = forgeElementBoxRect(targetEl);
|
const objRect = forgeElementBoxRect(targetEl);
|
||||||
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||||
@@ -153,85 +153,114 @@ function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
|||||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
}, 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
|
// 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"
|
// 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
|
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||||
// (affichage de bulle + attente de touche).
|
// (affichage de bulle + attente de touche).
|
||||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||||
if (action.type === 'dialogue') {
|
const handler = COLLISION_RULE_ACTION_HANDLERS[action.type];
|
||||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
if (handler) handler(action, targetEl, obj);
|
||||||
// — 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeCollisionRuleTick(screenData) {
|
function forgeCollisionRuleTick(screenData) {
|
||||||
@@ -242,7 +271,7 @@ function forgeCollisionRuleTick(screenData) {
|
|||||||
|
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => {
|
||||||
const rules = obj.collision_rules;
|
const rules = obj.collision_rules;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||||
@@ -261,7 +290,7 @@ function forgeCollisionRuleTick(screenData) {
|
|||||||
if (rule.action.type === 'interagir') {
|
if (rule.action.type === 'interagir') {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
anyInteractActive = true;
|
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;
|
forgeCollisionRuleActiveState[stateKey] = isActive;
|
||||||
return;
|
return;
|
||||||
@@ -280,10 +309,10 @@ function forgeCollisionRuleTick(screenData) {
|
|||||||
function forgeStartCollisionRuleInteractListener(screenData) {
|
function forgeStartCollisionRuleInteractListener(screenData) {
|
||||||
activeCollisionRuleInteractListener = function (ev) {
|
activeCollisionRuleInteractListener = function (ev) {
|
||||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
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) => {
|
(screenData.elements || []).forEach((obj) => {
|
||||||
const rules = obj.collision_rules;
|
const rules = obj.collision_rules;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
rules.forEach((rule, ruleIndex) => {
|
rules.forEach((rule, ruleIndex) => {
|
||||||
@@ -314,7 +343,7 @@ function forgeBindClicSurvolRules(screenData) {
|
|||||||
forgeUnbindClicSurvolRules();
|
forgeUnbindClicSurvolRules();
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => {
|
||||||
const rules = obj.collision_rules;
|
const rules = obj.collision_rules;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
rules.forEach((rule) => {
|
rules.forEach((rule) => {
|
||||||
@@ -347,11 +376,11 @@ function forgeStopCollisionRuleControllers() {
|
|||||||
// ET au moins un objet avec des règles.
|
// ET au moins un objet avec des règles.
|
||||||
function forgeStartCollisionRuleControllers(screenData) {
|
function forgeStartCollisionRuleControllers(screenData) {
|
||||||
forgeStopCollisionRuleControllers();
|
forgeStopCollisionRuleControllers();
|
||||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
if (screenData?.kind !== 'jeu_2d') return;
|
||||||
const elements = screenData.elements || [];
|
const elements = screenData.elements || [];
|
||||||
forgeBindClicSurvolRules(screenData);
|
forgeBindClicSurvolRules(screenData);
|
||||||
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
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;
|
if (!hasPlayer || !hasRules) return;
|
||||||
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
||||||
forgeStartCollisionRuleInteractListener(screenData);
|
forgeStartCollisionRuleInteractListener(screenData);
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function compareValues(actual, operator, expected, fieldType) {
|
|||||||
const e = normalizeBool(expected);
|
const e = normalizeBool(expected);
|
||||||
return operator === 'different' ? a !== e : a === e;
|
return operator === 'different' ? a !== e : a === e;
|
||||||
}
|
}
|
||||||
const an = parseFloat(actual); const
|
const an = Number.parseFloat(actual); const
|
||||||
en = parseFloat(expected);
|
en = Number.parseFloat(expected);
|
||||||
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
||||||
if (numeric) {
|
if (numeric) {
|
||||||
switch (operator) {
|
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
|
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||||
// déroulant, voir screen_edit.html).
|
// déroulant, voir screen_edit.html).
|
||||||
function readVariableValue(name) {
|
function readVariableValue(name) {
|
||||||
const v = (gameData.variables || {})[name];
|
const v = gameData.variables?.[name];
|
||||||
return v ? v.value : undefined;
|
return v ? v.value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ function resolveVariablePath(rawValue, path) {
|
|||||||
let m;
|
let m;
|
||||||
while ((m = segmentRe.exec(path)) !== null) {
|
while ((m = segmentRe.exec(path)) !== null) {
|
||||||
if (current === null || current === undefined) return 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;
|
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__/
|
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
|
||||||
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
||||||
function forgeCollisionRectFromBox(box, collision) {
|
function forgeCollisionRectFromBox(box, collision) {
|
||||||
const width = (collision && collision.width) || box.width;
|
const width = collision?.width || box.width;
|
||||||
const height = (collision && collision.height) || box.height;
|
const height = collision?.height || box.height;
|
||||||
const offsetX = (collision && collision.offset_x) || 0;
|
const offsetX = collision?.offset_x || 0;
|
||||||
const offsetY = (collision && collision.offset_y) || 0;
|
const offsetY = collision?.offset_y || 0;
|
||||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
const top = box.top + (box.height - height) / 2 + offsetY;
|
||||||
const shape = (collision && collision.shape) || 'rectangle';
|
const shape = collision?.shape || 'rectangle';
|
||||||
return {
|
return {
|
||||||
left, top, right: left + width, bottom: top + height, width, height, shape,
|
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;
|
br = Math.min(b.width, b.height) / 2;
|
||||||
const dx = acx - bcx; const
|
const dx = acx - bcx; const
|
||||||
dy = acy - bcy;
|
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
|
// 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 objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
||||||
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
||||||
if (
|
if (
|
||||||
(objA && objA.collision && objA.collision.enabled === false)
|
objA?.collision?.enabled === false
|
||||||
|| (objB && objB.collision && objB.collision.enabled === false)
|
|| objB?.collision?.enabled === false
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA?.collision);
|
||||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
|
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB?.collision);
|
||||||
return forgeShapesOverlap(rectA, rectB);
|
return forgeShapesOverlap(rectA, rectB);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +215,9 @@ function evaluateConditionClause(clause) {
|
|||||||
if (source === 'variable') {
|
if (source === 'variable') {
|
||||||
const varName = clause.variable ?? clause.cond_variable;
|
const varName = clause.variable ?? clause.cond_variable;
|
||||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
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 actual = resolveVariablePath(readVariableValue(varName), path);
|
||||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
const fieldType = varInfo?.type === 'booleen' ? 'booleen' : '';
|
||||||
return compareValues(actual, operator, expected, fieldType);
|
return compareValues(actual, operator, expected, fieldType);
|
||||||
}
|
}
|
||||||
const actual = readFieldValue(
|
const actual = readFieldValue(
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
|||||||
|
|
||||||
function forgeEscapeHtml(str) {
|
function forgeEscapeHtml(str) {
|
||||||
return String(str == null ? '' : str)
|
return String(str == null ? '' : str)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||||
.replace(/"/g, '"');
|
.replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Boîte de dialogue ----------
|
// ---------- Boîte de dialogue ----------
|
||||||
@@ -141,7 +141,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
|||||||
function forgeEscapeCameraForFullscreen(widget) {
|
function forgeEscapeCameraForFullscreen(widget) {
|
||||||
if (widget.parentNode === document.body) return;
|
if (widget.parentNode === document.body) return;
|
||||||
const screenEl = widget.closest('.playScreen');
|
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');
|
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||||
document.body.appendChild(widget);
|
document.body.appendChild(widget);
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
|||||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
widget.classList.remove('is-active', 'is-fullscreen');
|
widget.classList.remove('is-active', 'is-fullscreen');
|
||||||
const objectId = widget.getAttribute('data-object-id');
|
const objectId = widget.dataset.objectId;
|
||||||
forgeStopQuizTimer(objectId);
|
forgeStopQuizTimer(objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ const forgeQuizTimerState = {};
|
|||||||
|
|
||||||
function forgeStopQuizTimer(objectId) {
|
function forgeStopQuizTimer(objectId) {
|
||||||
const t = forgeQuizTimerState[objectId];
|
const t = forgeQuizTimerState[objectId];
|
||||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
if (t?.intervalId) clearInterval(t.intervalId);
|
||||||
delete forgeQuizTimerState[objectId];
|
delete forgeQuizTimerState[objectId];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
|||||||
|
|
||||||
function forgeUpdateAllScoreWidgets() {
|
function forgeUpdateAllScoreWidgets() {
|
||||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
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();
|
forgeUpdateQuizBoxScoreDisplays();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ function forgeUpdateAllScoreWidgets() {
|
|||||||
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
||||||
function forgeUpdateQuizBoxScoreDisplays() {
|
function forgeUpdateQuizBoxScoreDisplays() {
|
||||||
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
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/
|
// 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
|
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
||||||
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
||||||
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||||
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
|
const stateKey = widget ? widget.dataset.objectId : (`_sansBoiteDialogue_${dialogueId}`);
|
||||||
forgeSyncDialogueStartedToScorm();
|
forgeSyncDialogueStartedToScorm();
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||||
@@ -400,7 +400,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
|||||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||||
function forgeDialogueBoxAdvance(objectId) {
|
function forgeDialogueBoxAdvance(objectId) {
|
||||||
const state = forgeDialogueBoxState[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);
|
forgeAdvanceConversation(objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ function forgeDialogueBoxAdvance(objectId) {
|
|||||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
const previousState = forgeQuizBoxState[quizObjectId];
|
const previousState = forgeQuizBoxState[quizObjectId];
|
||||||
forgeQuizBoxState[quizObjectId] = {
|
forgeQuizBoxState[quizObjectId] = {
|
||||||
dialogueObjectId,
|
dialogueObjectId,
|
||||||
@@ -456,14 +456,14 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
|||||||
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
||||||
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
||||||
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
// 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);
|
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
||||||
widget.classList.toggle('is-fullscreen', isFullscreen);
|
widget.classList.toggle('is-fullscreen', isFullscreen);
|
||||||
forgeUpdateQuizBoxScoreDisplays();
|
forgeUpdateQuizBoxScoreDisplays();
|
||||||
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
||||||
// imposé, voir screens/rendering/quiz_box_config.py).
|
// imposé, voir screens/rendering/quiz_box_config.py).
|
||||||
const timerMode = widget.getAttribute('data-timer-mode');
|
const timerMode = widget.dataset.timerMode;
|
||||||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
const timerSeconds = Number.parseInt(widget.dataset.timerSeconds, 10) || 0;
|
||||||
if (timerMode === 'question') {
|
if (timerMode === 'question') {
|
||||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
} 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
|
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
|
||||||
// son animation CSS.
|
// son animation CSS.
|
||||||
widget.classList.remove('is-active');
|
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');
|
widget.classList.add('is-active');
|
||||||
// Effet d'entrée propre au modèle (voir static/js/play/
|
// Effet d'entrée propre au modèle (voir static/js/play/
|
||||||
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
// 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
|
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
||||||
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
||||||
function forgeQuizTemplateEffectsFor(widget) {
|
function forgeQuizTemplateEffectsFor(widget) {
|
||||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
const template = widget?.dataset.quizTemplate || 'defaut';
|
||||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
|
// Surligne la bonne réponse en vert (et le choix cliqué en rouge s'il
|
||||||
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
|
// était faux) sur les boutons injectés par forgeShowQuizBox — extrait de
|
||||||
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
|
// forgeQuizBoxAnswer (lot 4 "modernisation JS", S3776).
|
||||||
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
|
function markQuizChoiceButtons(widget, state, choiceIndex) {
|
||||||
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
|
if (!widget) return;
|
||||||
// explicite : une mauvaise réponse ne doit jamais bloquer la
|
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||||
// progression) — surligne la bonne réponse en vert (et le choix cliqué
|
for (const [i, button] of buttons.entries()) {
|
||||||
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
|
button.disabled = true;
|
||||||
// second clic pendant cet affichage est ignoré (`resolved`).
|
if (i === state.correctIndex) button.classList.add('is-correct');
|
||||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
else if (i === choiceIndex) button.classList.add('is-wrong');
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
if (correct) {
|
||||||
window.forgeQuizCorrectAnswered += 1;
|
window.forgeQuizCorrectAnswered += 1;
|
||||||
state.correctCount += 1;
|
state.correctCount += 1;
|
||||||
@@ -533,6 +528,11 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
} else if (fx.onWrong) {
|
} else if (fx.onWrong) {
|
||||||
fx.onWrong(widget);
|
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') {
|
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||||
forgeXapiNotifyQuestionAnswered(
|
forgeXapiNotifyQuestionAnswered(
|
||||||
state.dialogueId,
|
state.dialogueId,
|
||||||
@@ -543,8 +543,6 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
correct ? state.rewardAmount : 0,
|
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') {
|
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
||||||
forgeScorm2004NotifyQuestionAnswered(
|
forgeScorm2004NotifyQuestionAnswered(
|
||||||
state.dialogueId,
|
state.dialogueId,
|
||||||
@@ -556,19 +554,50 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
state.correctIndex,
|
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
|
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul après
|
||||||
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
|
// un délai fixe (700ms/1400ms), jamais assez pour un lecteur lent ou un
|
||||||
// minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox
|
// utilisateur de lecteur d'écran — l'avancée dépend maintenant
|
||||||
// ci-dessus, TOUJOURS visible — demande explicite, comme la maquette
|
// uniquement d'un clic sur "Continuer →", jamais d'un minuteur. Le
|
||||||
// manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça
|
// bouton existe déjà (posé désactivé par forgeShowQuizBox, TOUJOURS
|
||||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
// visible — demande explicite, comme la maquette manga) : on se contente
|
||||||
// choix juste au-dessus).
|
// ici de l'ACTIVER, jamais de le recréer (ça effacerait le surlignage
|
||||||
if (widget) {
|
// vert/rouge qu'on vient de poser sur les choix). Extrait de
|
||||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
// forgeQuizBoxAnswer.
|
||||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
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/
|
// 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;
|
const { answeredCount } = state;
|
||||||
return function () {
|
return function () {
|
||||||
widget.classList.add('is-active');
|
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 });
|
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.clauses.length, 1);
|
||||||
assert.equal(decoded.combinator, 'ou');
|
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'));
|
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é', () => {
|
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||||
const el = {
|
const el = {
|
||||||
id: 11,
|
id: 11,
|
||||||
|
|||||||
@@ -18,53 +18,82 @@ function forgePyMod(a, b) {
|
|||||||
return ((a % b) + b) % 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) {
|
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||||
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||||
if (operation === 'definir_texte') return rawValue || '';
|
const handler = OPERATION_HANDLERS[operation];
|
||||||
if (operation === 'definir_nombre') {
|
if (!handler) throw new Error(`opération inconnue : ${operation}`);
|
||||||
const n = parseFloat(rawValue);
|
return handler(current, rawValue, round, isDecimal);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (Array.isArray(parsed)) clauses = parsed;
|
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) {
|
if (clauses === null && legacyPrefix) {
|
||||||
clauses = [];
|
clauses = [];
|
||||||
@@ -48,7 +56,7 @@ function forgeResolveVariablePath(rawValue, path) {
|
|||||||
let current;
|
let current;
|
||||||
try {
|
try {
|
||||||
current = rawValue ? JSON.parse(rawValue) : null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
let m;
|
let m;
|
||||||
@@ -57,9 +65,9 @@ function forgeResolveVariablePath(rawValue, path) {
|
|||||||
const key = m[1]; const
|
const key = m[1]; const
|
||||||
index = m[2];
|
index = m[2];
|
||||||
try {
|
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;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +99,7 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
|||||||
|
|
||||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||||
if (varMatch) {
|
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
|
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
||||||
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
||||||
}
|
}
|
||||||
@@ -103,8 +111,8 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
|||||||
const names = gameData.definition_names || {};
|
const names = gameData.definition_names || {};
|
||||||
for (const defId in names) {
|
for (const defId in names) {
|
||||||
if (names[defId] === objectName) {
|
if (names[defId] === objectName) {
|
||||||
const rows = gameData.data && gameData.data[defId];
|
const rows = gameData.data?.[defId];
|
||||||
if (rows && rows.length) {
|
if (rows?.length) {
|
||||||
return rows[0][fieldName];
|
return rows[0][fieldName];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -119,7 +127,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
|||||||
if (!fieldName) return true;
|
if (!fieldName) return true;
|
||||||
const operator = clause.operateur || 'egal';
|
const operator = clause.operateur || 'egal';
|
||||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
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 field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
||||||
const fieldType = field ? field.type : 'texte';
|
const fieldType = field ? field.type : 'texte';
|
||||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||||
@@ -127,7 +135,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
|||||||
|
|
||||||
// Port de filter_rows_by_clauses.
|
// Port de filter_rows_by_clauses.
|
||||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||||
if (!clauses || !clauses.length) return rows;
|
if (!clauses?.length) return rows;
|
||||||
if (combinator === 'ou') {
|
if (combinator === 'ou') {
|
||||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
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
|
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||||
// via forgeSpecialRenderer().
|
// 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,
|
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
||||||
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
// 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) {
|
function forgeHtmlEscape(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||||
.replace(/"/g, '"')
|
.replaceAll('"', '"')
|
||||||
.replace(/'/g, ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
// 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.
|
// Port de parent_flex_direction.py.
|
||||||
function forgeParentFlexDirection(parentEl) {
|
function forgeParentFlexDirection(parentEl) {
|
||||||
const style = (parentEl && parentEl.style) || {};
|
const style = parentEl?.style || {};
|
||||||
if (style.display !== 'flex') return null;
|
if (style.display !== 'flex') return null;
|
||||||
return style['flex-direction'] || 'row';
|
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
|
// 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).
|
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
||||||
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||||
const style = { ...el.style || {} };
|
const style = { ...el.style };
|
||||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
if (FORGE_LAYOUT_CAPABLE_WIDGETS.has(widgetKey) && !('display' in style)) {
|
||||||
style.display = 'flex';
|
style.display = 'flex';
|
||||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||||
}
|
}
|
||||||
@@ -150,13 +150,13 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
|||||||
const rawAttrs = el.attributes || {};
|
const rawAttrs = el.attributes || {};
|
||||||
const attrs = {};
|
const attrs = {};
|
||||||
Object.keys(rawAttrs).forEach((k) => {
|
Object.keys(rawAttrs).forEach((k) => {
|
||||||
if (k.indexOf('_') === 0) return;
|
if (k.startsWith('_')) return;
|
||||||
const v = rawAttrs[k];
|
const v = rawAttrs[k];
|
||||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||||
});
|
});
|
||||||
Object.assign(attrs, meta.fixedAttrs || {});
|
Object.assign(attrs, meta.fixedAttrs || {});
|
||||||
const extraClasses = Object.keys(rawAttrs)
|
const extraClasses = Object.keys(rawAttrs)
|
||||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
.filter((k) => k.startsWith('_class_') && rawAttrs[k])
|
||||||
.map((k) => rawAttrs[k]);
|
.map((k) => rawAttrs[k]);
|
||||||
if (extraClasses.length) {
|
if (extraClasses.length) {
|
||||||
const baseClass = attrs.class || '';
|
const baseClass = attrs.class || '';
|
||||||
@@ -170,7 +170,7 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
|||||||
|
|
||||||
// Port de render_children.py.
|
// Port de render_children.py.
|
||||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
const kids = childrenMap?.[el.id] || [];
|
||||||
const flexDir = forgeParentFlexDirection(el);
|
const flexDir = forgeParentFlexDirection(el);
|
||||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
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;
|
let definitionId = attrs._data_definition_id;
|
||||||
if (!definitionId) return null;
|
if (!definitionId) return null;
|
||||||
definitionId = String(definitionId);
|
definitionId = String(definitionId);
|
||||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||||
if (!fieldsMetaForDef) return null;
|
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');
|
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
||||||
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||||
if (!rows.length) return null;
|
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
|
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
||||||
// d'éventuels enfants copiés en base.
|
// d'éventuels enfants copiés en base.
|
||||||
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
const templateTree = gameData.element_type_templates?.[String(el.element_type_id)];
|
||||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
if (!templateTree?.top.length) return [null, false];
|
||||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||||
let { top } = templateTree;
|
let { top } = templateTree;
|
||||||
if (isOverlayOnly) {
|
if (isOverlayOnly) {
|
||||||
@@ -255,7 +255,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
const attrsRaw = el.attributes || {};
|
const attrsRaw = el.attributes || {};
|
||||||
|
|
||||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
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)) {
|
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
||||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
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);
|
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||||
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
|
if (boundCtx) ctx = { ...ctx, ...boundCtx };
|
||||||
|
|
||||||
// meta d'un widget à special_render : fixedAttrs propre (voir
|
// meta d'un widget à special_render : fixedAttrs propre (voir
|
||||||
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
// 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}>`);
|
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}>`);
|
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
function forgeHexToRgb(hexColor) {
|
function forgeHexToRgb(hexColor) {
|
||||||
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
||||||
if (hex.length !== 6) hex = '5b8cff';
|
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) {
|
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||||
const lo = forgeHexToRgb(lowHex); const
|
const lo = forgeHexToRgb(lowHex); const
|
||||||
hi = forgeHexToRgb(highHex);
|
hi = forgeHexToRgb(highHex);
|
||||||
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
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(forgeByteToHex).join('')}`;
|
||||||
return `#${mixed.map(toHex).join('')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
// 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) {
|
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||||
if (!definitionId || !fieldName) return null;
|
if (!definitionId || !fieldName) return null;
|
||||||
definitionId = String(definitionId);
|
definitionId = String(definitionId);
|
||||||
const rows = gameData.data && gameData.data[definitionId];
|
const rows = gameData.data?.[definitionId];
|
||||||
if (!rows || !rows.length) return null;
|
if (!rows?.length) return null;
|
||||||
let row = null;
|
let row = null;
|
||||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||||
if (!row) row = rows[0];
|
if (!row) row = rows[0];
|
||||||
return row[fieldName];
|
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) {
|
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const attrs = el.attributes || {};
|
const attrs = el.attributes || {};
|
||||||
const nomPosition = attrs._nom_position || 'dessus';
|
const nomPosition = attrs._nom_position || 'dessus';
|
||||||
@@ -47,14 +85,14 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
const rowId = attrs._row_id;
|
const rowId = attrs._row_id;
|
||||||
const fieldName = (attrs._champ || '').trim();
|
const fieldName = (attrs._champ || '').trim();
|
||||||
const fieldNameNom = (attrs._champ_nom || '').trim();
|
const fieldNameNom = (attrs._champ_nom || '').trim();
|
||||||
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
let minV = Number.parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||||
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
let maxV = Number.parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||||
const afficherValeur = attrs._afficher_valeur === '1';
|
const afficherValeur = attrs._afficher_valeur === '1';
|
||||||
|
|
||||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||||
let value = parseFloat(rawValue);
|
let value = Number.parseFloat(rawValue);
|
||||||
if (Number.isNaN(value)) value = minV;
|
if (Number.isNaN(value)) value = minV;
|
||||||
|
|
||||||
const span = (maxV - minV) || 1;
|
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 percent = Math.round(ratio * 1000) / 10;
|
||||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||||
|
|
||||||
let labelHtml = '';
|
const labelHtml = forgeJaugeValueLabelHtml(afficherValeur, rawValue);
|
||||||
if (afficherValeur) {
|
const nomStyle = {
|
||||||
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
nomPolice, nomTaille, nomGras, nomItalique,
|
||||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
};
|
||||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
const nameHtml = forgeJaugeNameLabelHtml(
|
||||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
gameData,
|
||||||
}
|
definitionId,
|
||||||
|
rowId,
|
||||||
let nameHtml = '';
|
fieldNameNom,
|
||||||
if (fieldNameNom) {
|
nomPosition,
|
||||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
nomAlignement,
|
||||||
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
|
nomStyle,
|
||||||
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 extraClasses = Object.keys(attrs)
|
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(' ');
|
.map((k) => attrs[k]).join(' ');
|
||||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
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,
|
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
||||||
// mais le port reste fidèle pour ne pas diverger inutilement).
|
// mais le port reste fidèle pour ne pas diverger inutilement).
|
||||||
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
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 attrsRaw = el.attributes || {};
|
||||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||||
const radius = attrsRaw._arrondi || '12';
|
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 definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
||||||
const templateTypeId = attrsRaw._template_type_id;
|
const templateTypeId = attrsRaw._template_type_id;
|
||||||
const template = forgeApplyCtx(el.content || '', ctx);
|
const template = forgeApplyCtx(el.content || '', ctx);
|
||||||
const children = (childrenMap && childrenMap[el.id]) || [];
|
const children = childrenMap?.[el.id] || [];
|
||||||
|
|
||||||
if (!definitionId) {
|
if (!definitionId) {
|
||||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
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>`;
|
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
||||||
}
|
}
|
||||||
definitionId = String(definitionId);
|
definitionId = String(definitionId);
|
||||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||||
if (!fieldsMetaForDef) {
|
if (!fieldsMetaForDef) {
|
||||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
}"><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;
|
let templateTree = null;
|
||||||
if (templateTypeId) {
|
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 items = rows.map((row) => {
|
||||||
const rowCtx = { ...ctx || {} };
|
const rowCtx = { ...ctx };
|
||||||
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
// 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
|
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
||||||
// donc directement la bonne valeur, y compris pour un champ "relation"
|
// 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) {
|
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
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 content = forgeApplyCtx(el.content || '', ctx);
|
||||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||||
const rows = content.split('\n').filter((r) => r.trim());
|
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
|
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
||||||
// "icone", posés directement ou nichés dans un élément de jeu
|
// "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.
|
// 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}');`;
|
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
|
// 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
|
// 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) {
|
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 attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||||
const tabsExtraClasses = attrs.class || '';
|
const tabsExtraClasses = attrs.class || '';
|
||||||
delete 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) {
|
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||||
attrs['data-personnage'] = '1';
|
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 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;
|
if (idleFrame) attrs.src = idleFrame;
|
||||||
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
// 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.
|
// décoratif sur le widget "image" générique.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ let activePersonnageInteractListener = null;
|
|||||||
const FORGE_PERSONNAGE_FPS = 8;
|
const FORGE_PERSONNAGE_FPS = 8;
|
||||||
|
|
||||||
function forgePersonnageAnimExists(elementId, animName) {
|
function forgePersonnageAnimExists(elementId, animName) {
|
||||||
const byAnim = (gameData.personnage_animations || {})[elementId];
|
const byAnim = gameData.personnage_animations?.[elementId];
|
||||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
return !!byAnim?.[animName]?.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
|
// "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 player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||||
const px = playerEl
|
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;
|
: world.width / 2;
|
||||||
const py = playerEl
|
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;
|
: world.height / 2;
|
||||||
const offsetX = Math.min(
|
const offsetX = Math.min(
|
||||||
Math.max(px - screenData.scene_width / 2, 0),
|
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
|
// objet dont la collision est explicitement désactivée, ni l'objet qui
|
||||||
// se déplace lui-même.
|
// se déplace lui-même.
|
||||||
function forgeSolidObstacles(screenData, movingId) {
|
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),
|
// 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) {
|
function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) {
|
||||||
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
|
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
|
||||||
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
||||||
for (let i = 0; i < obstacles.length; i++) {
|
for (const other of obstacles) {
|
||||||
const other = obstacles[i];
|
|
||||||
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
||||||
if (!otherEl) continue;
|
if (!otherEl) continue;
|
||||||
const otherLeft = parseFloat(otherEl.style.left) || 0;
|
const otherLeft = Number.parseFloat(otherEl.style.left) || 0;
|
||||||
const otherTop = parseFloat(otherEl.style.top) || 0;
|
const otherTop = Number.parseFloat(otherEl.style.top) || 0;
|
||||||
const otherWidth = parseFloat(otherEl.style.width) || 0;
|
const otherWidth = Number.parseFloat(otherEl.style.width) || 0;
|
||||||
const otherHeight = parseFloat(otherEl.style.height) || 0;
|
const otherHeight = Number.parseFloat(otherEl.style.height) || 0;
|
||||||
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
||||||
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||||
// (triggers.js), pour un déplacement perçu comme continu.
|
// (triggers.js), pour un déplacement perçu comme continu.
|
||||||
function forgeRunPersonnageControllerTick(screenData) {
|
function forgeRunPersonnageControllerTick(screenData) {
|
||||||
forgeUpdateSceneCamera(screenData);
|
forgeUpdateSceneCamera(screenData);
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => 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;
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
|
// 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()).
|
// réinitialisé à chaque changement d'écran (voir showScreen()).
|
||||||
function forgeStartPersonnageControllers(screenData) {
|
function forgeStartPersonnageControllers(screenData) {
|
||||||
forgeStopPersonnageControllers();
|
forgeStopPersonnageControllers();
|
||||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
if (screenData?.kind !== 'jeu_2d') return;
|
||||||
const elements = screenData.elements || [];
|
const elements = screenData.elements || [];
|
||||||
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||||
const hasFond = elements.some((o) => o.kind === 'fond');
|
const hasFond = elements.some((o) => o.kind === 'fond');
|
||||||
|
|||||||
@@ -15,27 +15,38 @@
|
|||||||
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
||||||
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
||||||
// classique (pas de module), lisibles ici telles quelles.
|
// 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
|
// `progressSelector` : chaque modèle a son propre conteneur de barre
|
||||||
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
||||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||||
if (!quizState) return;
|
if (!quizState) return;
|
||||||
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||||
if (!dialogueState) return;
|
if (!dialogueState) return;
|
||||||
function isQuestion(line) { return line && line.type === 'question'; }
|
const total = dialogueState.lines.filter(isQuestionLine).length;
|
||||||
const total = dialogueState.lines.filter(isQuestion).length;
|
|
||||||
if (total === 0) return;
|
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"]');
|
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||||
if (header) header.textContent = `Question ${current}/${total}`;
|
if (header) header.textContent = `Question ${current}/${total}`;
|
||||||
const progressEl = widget.querySelector(progressSelector);
|
const progressEl = widget.querySelector(progressSelector);
|
||||||
if (progressEl) {
|
if (progressEl) {
|
||||||
const bars = [];
|
const bars = [];
|
||||||
for (let i = 1; i <= total; i++) {
|
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('');
|
progressEl.innerHTML = bars.join('');
|
||||||
}
|
}
|
||||||
@@ -79,15 +90,15 @@ function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
|||||||
const content = widget.querySelector('.classiqueContent');
|
const content = widget.querySelector('.classiqueContent');
|
||||||
const zone = widget.querySelector('[data-classique-role="result"]');
|
const zone = widget.querySelector('[data-classique-role="result"]');
|
||||||
if (!content || !zone) return;
|
if (!content || !zone) return;
|
||||||
const total = (result && result.totalCount) || 0;
|
const total = result?.totalCount || 0;
|
||||||
const correct = (result && result.correctCount) || 0;
|
const correct = result?.correctCount || 0;
|
||||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||||
const passed = pct >= threshold;
|
const passed = pct >= threshold;
|
||||||
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||||
const title = titleEl ? titleEl.textContent : 'Quiz';
|
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>`
|
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="classiqueResultTitle">${correct} bonne${correct > 1 ? 's' : ''} réponse${correct > 1 ? 's' : ''} sur ${total}</p>`
|
||||||
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
||||||
@@ -112,13 +123,13 @@ function forgeQuizMangaOnQuizComplete(widget, result) {
|
|||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
const zone = widget.querySelector('[data-manga-role="result"]');
|
const zone = widget.querySelector('[data-manga-role="result"]');
|
||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
const total = (result && result.totalCount) || 0;
|
const total = result?.totalCount || 0;
|
||||||
const correct = (result && result.correctCount) || 0;
|
const correct = result?.correctCount || 0;
|
||||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||||
const passed = pct >= threshold;
|
const passed = pct >= threshold;
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
zone.innerHTML = '<div class="mangaResultCard">'
|
zone.innerHTML = '<div class="mangaResultCard">'
|
||||||
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
||||||
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
function forgeRunScreenTriggers(screenData) {
|
function forgeRunScreenTriggers(screenData) {
|
||||||
if (!screenData) return;
|
if (!screenData) return;
|
||||||
const rules = screenData.screen_triggers;
|
const rules = screenData.screen_triggers;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
rules.forEach((rule) => {
|
rules.forEach((rule) => {
|
||||||
if (rule.trigger !== 'affichage') return;
|
if (rule.trigger !== 'affichage') return;
|
||||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function runScreenBackgroundMusic(screenId) {
|
|||||||
currentBackgroundAudio = null;
|
currentBackgroundAudio = null;
|
||||||
}
|
}
|
||||||
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
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;
|
if (!url) return;
|
||||||
currentBackgroundAudio = new Audio(url);
|
currentBackgroundAudio = new Audio(url);
|
||||||
currentBackgroundAudio.loop = true;
|
currentBackgroundAudio.loop = true;
|
||||||
@@ -94,8 +94,8 @@ function runScreenBackgroundMusic(screenId) {
|
|||||||
// que soit la forme réelle de la fenêtre du joueur.
|
// que soit la forme réelle de la fenêtre du joueur.
|
||||||
function applyScreenAspect(aspect) {
|
function applyScreenAspect(aspect) {
|
||||||
const parts = String(aspect).split('/');
|
const parts = String(aspect).split('/');
|
||||||
const w = parseFloat(parts[0]) || 9; const
|
const w = Number.parseFloat(parts[0]) || 9; const
|
||||||
h = parseFloat(parts[1]) || 16;
|
h = Number.parseFloat(parts[1]) || 16;
|
||||||
const frame = document.getElementById('playFrame');
|
const frame = document.getElementById('playFrame');
|
||||||
if (frame) {
|
if (frame) {
|
||||||
frame.style.setProperty('--ratio-w', w);
|
frame.style.setProperty('--ratio-w', w);
|
||||||
@@ -231,12 +231,12 @@ function collectAnimationClips(screenId) {
|
|||||||
function visit(sid) {
|
function visit(sid) {
|
||||||
if (visited[sid]) return;
|
if (visited[sid]) return;
|
||||||
visited[sid] = true;
|
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));
|
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
||||||
if (!screenData) return;
|
if (!screenData) return;
|
||||||
screenData.elements.forEach((el) => {
|
screenData.elements.forEach((el) => {
|
||||||
if (!el.element_type_id) return;
|
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);
|
if (templateScreenId != null) visit(templateScreenId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function bindHoverTexts() {
|
|||||||
// Espace déclenchent le même comportement que le clic.
|
// Espace déclenchent le même comportement que le clic.
|
||||||
function bindClicks() {
|
function bindClicks() {
|
||||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
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');
|
const triggers = findTriggerNodess(elementId, 'clic');
|
||||||
if (triggers.length) {
|
if (triggers.length) {
|
||||||
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
// 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é,
|
// 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.
|
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
||||||
const rowEl = ev.target.closest('.repeaterItem');
|
const rowEl = ev.target.closest('.repeaterItem');
|
||||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
window.lastClickedRowId = rowEl ? Number.parseInt(rowEl.dataset.rowId, 10) : null;
|
||||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 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
|
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||||
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||||
@@ -165,7 +166,7 @@ function bindClicks() {
|
|||||||
function bindHoverTriggers() {
|
function bindHoverTriggers() {
|
||||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||||
if (elDiv.dataset.hoverTriggerBound) return;
|
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 enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||||
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
||||||
if (!enterTriggers.length && !leaveTriggers.length) return;
|
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
|
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||||
// pour celles-ci.
|
// 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
|
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||||
@@ -242,7 +242,7 @@ function renderCollisionWizardAttendreStep(onChoose) {
|
|||||||
|
|
||||||
function collisionWizardAttendreNext() {
|
function collisionWizardAttendreNext() {
|
||||||
const raw = document.getElementById('attendreWizSecondes').value;
|
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;
|
const onChoose = _collisionWizard.onAttendreChosen;
|
||||||
_collisionWizard.onAttendreChosen = null;
|
_collisionWizard.onAttendreChosen = null;
|
||||||
onChoose(secondes);
|
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; }
|
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})">`
|
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>`
|
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
||||||
+ `<span>${(o.name || '').replace(/</g, '<')}</span>`
|
+ `<span>${(o.name || '').replaceAll('<', '<')}</span>`
|
||||||
+ '</button>').join('');
|
+ '</button>').join('');
|
||||||
_collisionWizard.onObjectTargetChosen = onChoose;
|
_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>`;
|
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;
|
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>'
|
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel fichier (voir "Mes assets") ?</p>'
|
||||||
+ `<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">${options}</select>`
|
+ `<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">${options}</select>`
|
||||||
+ '<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
+ '<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
||||||
@@ -364,7 +364,7 @@ function collisionWizardIndicationNext() {
|
|||||||
const action = {
|
const action = {
|
||||||
type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte,
|
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;
|
_collisionWizard.pendingIndicationObjectId = null;
|
||||||
const onChoose = _collisionWizard.onIndicationChosen;
|
const onChoose = _collisionWizard.onIndicationChosen;
|
||||||
_collisionWizard.onIndicationChosen = null;
|
_collisionWizard.onIndicationChosen = null;
|
||||||
@@ -418,7 +418,7 @@ function renderCollisionWizardVariableStep(onChoose) {
|
|||||||
function collisionWizardVariableOperationChanged() {
|
function collisionWizardVariableOperationChanged() {
|
||||||
const operation = document.getElementById('varWizOperation').value;
|
const operation = document.getElementById('varWizOperation').value;
|
||||||
const valueInput = document.getElementById('varWizValue');
|
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() {
|
function collisionWizardVariableNext() {
|
||||||
@@ -429,7 +429,7 @@ function collisionWizardVariableNext() {
|
|||||||
target_variable: document.getElementById('varWizVariable').value,
|
target_variable: document.getElementById('varWizVariable').value,
|
||||||
data_operation: operation,
|
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;
|
action.data_value = document.getElementById('varWizValue').value;
|
||||||
}
|
}
|
||||||
const onChoose = _collisionWizard.onVariableChosen;
|
const onChoose = _collisionWizard.onVariableChosen;
|
||||||
@@ -456,9 +456,10 @@ function collisionWizardVariableNext() {
|
|||||||
// dialogue, "attendre" disparaissait silencieusement.
|
// dialogue, "attendre" disparaissait silencieusement.
|
||||||
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
||||||
headAction = headAction || leafAction;
|
headAction = headAction || leafAction;
|
||||||
|
const markCompletedChecked = leafAction.mark_completed ? ' checked' : '';
|
||||||
const completionHtml = leafAction.type === 'dialogue'
|
const completionHtml = leafAction.type === 'dialogue'
|
||||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">'
|
? '<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>'
|
+ '</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('');
|
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`, {
|
fetch(`/game/${GAME_SLUG}/variables/new`, {
|
||||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
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) => {
|
.then((json) => {
|
||||||
if (!json.row_html) { window.location.reload(); return; }
|
if (!json.row_html) { window.location.reload(); return; }
|
||||||
const tbody = document.getElementById('globalVariablesBody');
|
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),
|
// posé en base à ce stade (voir screens/scenes/add_scene_object.py),
|
||||||
// seul un rechargement peut alors l'afficher.
|
// seul un rechargement peut alors l'afficher.
|
||||||
const contentType = r.headers.get('content-type') || '';
|
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();
|
window.location.reload();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ function onCanvasDrop(ev) {
|
|||||||
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
||||||
// qui n'ont rien à "poser".
|
// qui n'ont rien à "poser".
|
||||||
function uploadUserAsset(inputEl, sceneKind) {
|
function uploadUserAsset(inputEl, sceneKind) {
|
||||||
const file = inputEl.files && inputEl.files[0];
|
const file = inputEl.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append('file', file);
|
body.append('file', file);
|
||||||
@@ -193,14 +193,14 @@ function deleteSceneObject(id) {
|
|||||||
})
|
})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
const contentType = r.headers.get('content-type') || '';
|
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();
|
window.location.reload();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (!json || !json.ok) return;
|
if (!json?.ok) return;
|
||||||
const el = document.getElementById(`el-${id}`);
|
const el = document.getElementById(`el-${id}`);
|
||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||||
@@ -227,8 +227,8 @@ function deleteSceneObject(id) {
|
|||||||
function applyCameraSize() {
|
function applyCameraSize() {
|
||||||
const wEl = document.getElementById('scene-camera-w'); const
|
const wEl = document.getElementById('scene-camera-w'); const
|
||||||
hEl = document.getElementById('scene-camera-h');
|
hEl = document.getElementById('scene-camera-h');
|
||||||
const width = parseInt(wEl.value, 10) || 1; const
|
const width = Number.parseInt(wEl.value, 10) || 1; const
|
||||||
height = parseInt(hEl.value, 10) || 1;
|
height = Number.parseInt(hEl.value, 10) || 1;
|
||||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -325,10 +325,10 @@ function onSceneObjectMouseDown(e, id) {
|
|||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
let lastClientX = e.clientX; let
|
let lastClientX = e.clientX; let
|
||||||
lastClientY = e.clientY;
|
lastClientY = e.clientY;
|
||||||
let curLeft = parseFloat(el.style.left) || 0; let
|
let curLeft = Number.parseFloat(el.style.left) || 0; let
|
||||||
curTop = parseFloat(el.style.top) || 0;
|
curTop = Number.parseFloat(el.style.top) || 0;
|
||||||
const width = parseFloat(el.style.width) || 0; const
|
const width = Number.parseFloat(el.style.width) || 0; const
|
||||||
height = parseFloat(el.style.height) || 0;
|
height = Number.parseFloat(el.style.height) || 0;
|
||||||
let moved = false;
|
let moved = false;
|
||||||
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
||||||
// .floatPanel, z-index:60) restent DÉLIBÉRÉMENT en position:fixed
|
// .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}`);
|
const el = document.getElementById(`el-${id}`);
|
||||||
let lastClientX = e.clientX; let
|
let lastClientX = e.clientX; let
|
||||||
lastClientY = e.clientY;
|
lastClientY = e.clientY;
|
||||||
let curWidth = parseFloat(el.style.width) || 0; let
|
let curWidth = Number.parseFloat(el.style.width) || 0; let
|
||||||
curHeight = parseFloat(el.style.height) || 0;
|
curHeight = Number.parseFloat(el.style.height) || 0;
|
||||||
const left = parseFloat(el.style.left) || 0; const
|
const left = Number.parseFloat(el.style.left) || 0; const
|
||||||
top = parseFloat(el.style.top) || 0;
|
top = Number.parseFloat(el.style.top) || 0;
|
||||||
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
||||||
// des panneaux flottants le temps du geste, pour rester visible même
|
// des panneaux flottants le temps du geste, pour rester visible même
|
||||||
// en redimensionnant sous l'un d'eux.
|
// en redimensionnant sous l'un d'eux.
|
||||||
@@ -459,10 +459,10 @@ function initScenePosFields() {
|
|||||||
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
||||||
function apply() {
|
function apply() {
|
||||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||||
const x = parseFloat(xEl.value) || 0; const
|
const x = Number.parseFloat(xEl.value) || 0; const
|
||||||
y = parseFloat(yEl.value) || 0;
|
y = Number.parseFloat(yEl.value) || 0;
|
||||||
const w = parseFloat(wEl.value) || 1; const
|
const w = Number.parseFloat(wEl.value) || 1; const
|
||||||
h = parseFloat(hEl.value) || 1;
|
h = Number.parseFloat(hEl.value) || 1;
|
||||||
if (canvasEl) {
|
if (canvasEl) {
|
||||||
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
||||||
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
||||||
@@ -483,7 +483,7 @@ initScenePosFields();
|
|||||||
function swapSceneObjectCharacter(forgeCharacter) {
|
function swapSceneObjectCharacter(forgeCharacter) {
|
||||||
const body = document.getElementById('personnagePanelBody');
|
const body = document.getElementById('personnagePanelBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
||||||
});
|
});
|
||||||
@@ -508,7 +508,7 @@ function swapSceneObjectCharacter(forgeCharacter) {
|
|||||||
function saveSceneObjectRole(role) {
|
function saveSceneObjectRole(role) {
|
||||||
const body = document.getElementById('personnagePanelBody');
|
const body = document.getElementById('personnagePanelBody');
|
||||||
if (!body) return;
|
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`, {
|
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/role`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -527,19 +527,19 @@ function saveSceneObjectRole(role) {
|
|||||||
function saveSceneObjectCollision() {
|
function saveSceneObjectCollision() {
|
||||||
const body = document.getElementById('collisionPanelBody');
|
const body = document.getElementById('collisionPanelBody');
|
||||||
if (!body) return;
|
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 enabled = document.getElementById('collision-enabled').checked;
|
||||||
const shape = document.getElementById('collision-shape').value;
|
const shape = document.getElementById('collision-shape').value;
|
||||||
const width = parseFloat(document.getElementById('collision-width').value) || 0;
|
const width = Number.parseFloat(document.getElementById('collision-width').value) || 0;
|
||||||
const height = parseFloat(document.getElementById('collision-height').value) || 0;
|
const height = Number.parseFloat(document.getElementById('collision-height').value) || 0;
|
||||||
const offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
const offsetX = Number.parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||||
const offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
const offsetY = Number.parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||||
|
|
||||||
const overlay = document.getElementById('collisionBoxOverlay');
|
const overlay = document.getElementById('collisionBoxOverlay');
|
||||||
const objEl = document.getElementById(`el-${objectId}`);
|
const objEl = document.getElementById(`el-${objectId}`);
|
||||||
if (overlay && objEl) {
|
if (overlay && objEl) {
|
||||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||||
objHeight = parseFloat(objEl.style.height) || 0;
|
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||||
overlay.style.width = `${width}px`;
|
overlay.style.width = `${width}px`;
|
||||||
overlay.style.height = `${height}px`;
|
overlay.style.height = `${height}px`;
|
||||||
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
||||||
@@ -579,14 +579,14 @@ function onCollisionBoxMouseDown(e, id) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const overlay = document.getElementById('collisionBoxOverlay');
|
const overlay = document.getElementById('collisionBoxOverlay');
|
||||||
const objEl = document.getElementById(`el-${id}`);
|
const objEl = document.getElementById(`el-${id}`);
|
||||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||||
objHeight = parseFloat(objEl.style.height) || 0;
|
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startLeft = parseFloat(overlay.style.left) || 0; const
|
const startLeft = Number.parseFloat(overlay.style.left) || 0; const
|
||||||
startTop = parseFloat(overlay.style.top) || 0;
|
startTop = Number.parseFloat(overlay.style.top) || 0;
|
||||||
const boxWidth = parseFloat(overlay.style.width) || 0; const
|
const boxWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||||||
boxHeight = parseFloat(overlay.style.height) || 0;
|
boxHeight = Number.parseFloat(overlay.style.height) || 0;
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
||||||
@@ -595,8 +595,8 @@ function onCollisionBoxMouseDown(e, id) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
const offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
const offsetX = (Number.parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||||
const offsetY = (parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
const offsetY = (Number.parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||||
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
||||||
saveSceneObjectCollision();
|
saveSceneObjectCollision();
|
||||||
}
|
}
|
||||||
@@ -614,14 +614,14 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const overlay = document.getElementById('collisionBoxOverlay');
|
const overlay = document.getElementById('collisionBoxOverlay');
|
||||||
const objEl = document.getElementById(`el-${id}`);
|
const objEl = document.getElementById(`el-${id}`);
|
||||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||||
objHeight = parseFloat(objEl.style.height) || 0;
|
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startWidth = parseFloat(overlay.style.width) || 0; const
|
const startWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||||||
startHeight = parseFloat(overlay.style.height) || 0;
|
startHeight = Number.parseFloat(overlay.style.height) || 0;
|
||||||
const left = parseFloat(overlay.style.left) || 0; const
|
const left = Number.parseFloat(overlay.style.left) || 0; const
|
||||||
top = parseFloat(overlay.style.top) || 0;
|
top = Number.parseFloat(overlay.style.top) || 0;
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
||||||
@@ -630,8 +630,8 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
const newWidth = parseFloat(overlay.style.width); const
|
const newWidth = Number.parseFloat(overlay.style.width); const
|
||||||
newHeight = parseFloat(overlay.style.height);
|
newHeight = Number.parseFloat(overlay.style.height);
|
||||||
const offsetX = left - (objWidth - newWidth) / 2;
|
const offsetX = left - (objWidth - newWidth) / 2;
|
||||||
const offsetY = top - (objHeight - newHeight) / 2;
|
const offsetY = top - (objHeight - newHeight) / 2;
|
||||||
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
||||||
@@ -650,12 +650,12 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
|||||||
function saveSceneObjectCommands() {
|
function saveSceneObjectCommands() {
|
||||||
const body = document.getElementById('personnageCommandsBody');
|
const body = document.getElementById('personnageCommandsBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
const extra = [];
|
const extra = [];
|
||||||
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
||||||
const toucheEl = row.querySelector('.personnageKeyCapture');
|
const toucheEl = row.querySelector('.personnageKeyCapture');
|
||||||
const animEl = row.querySelector('.cmdExtraAnim');
|
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 });
|
extra.push({ touche: toucheEl.value, animation: animEl.value });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -713,7 +713,7 @@ function personnageCommandExtraRowEl(objectId, touche, animation) {
|
|||||||
row.className = 'field is-grouped personnageCommandExtraRow';
|
row.className = 'field is-grouped personnageCommandExtraRow';
|
||||||
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
||||||
const options = animNames.map((name) => `<option value="${name}"${name === animation ? ' selected' : ''}>${name}</option>`).join('');
|
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"><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>';
|
+ '<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
||||||
const keyInput = row.querySelector('.personnageKeyCapture');
|
const keyInput = row.querySelector('.personnageKeyCapture');
|
||||||
@@ -727,7 +727,7 @@ function addPersonnageCommandExtraRow() {
|
|||||||
const body = document.getElementById('personnageCommandsBody');
|
const body = document.getElementById('personnageCommandsBody');
|
||||||
const container = document.getElementById('personnageCommandsExtra');
|
const container = document.getElementById('personnageCommandsExtra');
|
||||||
if (!body || !container) return;
|
if (!body || !container) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +767,7 @@ function refreshSceneObjectNames() {
|
|||||||
function forgeHexToRgb(hex) {
|
function forgeHexToRgb(hex) {
|
||||||
hex = String(hex || '').replace('#', '');
|
hex = String(hex || '').replace('#', '');
|
||||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
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];
|
if (Number.isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||||
}
|
}
|
||||||
@@ -820,7 +820,7 @@ function saveDialogueBoxStyle(objectId) {
|
|||||||
};
|
};
|
||||||
checkDialogueBoxContrast(style);
|
checkDialogueBoxContrast(style);
|
||||||
const el = document.getElementById(`el-${objectId}`);
|
const el = document.getElementById(`el-${objectId}`);
|
||||||
const widget = el && el.querySelector('.dialogueBoxWidget');
|
const widget = el?.querySelector('.dialogueBoxWidget');
|
||||||
if (widget) {
|
if (widget) {
|
||||||
widget.style.fontFamily = style.font_family;
|
widget.style.fontFamily = style.font_family;
|
||||||
widget.style.fontSize = `${style.font_size}px`;
|
widget.style.fontSize = `${style.font_size}px`;
|
||||||
@@ -847,10 +847,10 @@ function saveDialogueBoxStyle(objectId) {
|
|||||||
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.setAttribute('data-selected-template', key);
|
grid.dataset.selectedTemplate = key;
|
||||||
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
||||||
for (let i = 0; i < tiles.length; i++) {
|
for (const tile of tiles) {
|
||||||
tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key);
|
tile.classList.toggle('is-selected', tile.dataset.template === key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveQuizBoxConfig(objectId);
|
saveQuizBoxConfig(objectId);
|
||||||
@@ -887,10 +887,10 @@ function saveQuizBoxConfig(objectId) {
|
|||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
||||||
const selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
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 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 = {
|
const config = {
|
||||||
fullscreen: fullscreenEl.checked,
|
fullscreen: fullscreenEl.checked,
|
||||||
timer_mode: document.getElementById('quiz-timer-mode').value,
|
timer_mode: document.getElementById('quiz-timer-mode').value,
|
||||||
@@ -907,7 +907,7 @@ function saveQuizBoxConfig(objectId) {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (fullscreenChanged) { window.location.reload(); return; }
|
if (fullscreenChanged) { window.location.reload(); return; }
|
||||||
const el = document.getElementById(`el-${objectId}`);
|
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;
|
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -915,7 +915,7 @@ function saveQuizBoxConfig(objectId) {
|
|||||||
function initPersonnageCommandsPanel() {
|
function initPersonnageCommandsPanel() {
|
||||||
const body = document.getElementById('personnageCommandsBody');
|
const body = document.getElementById('personnageCommandsBody');
|
||||||
if (!body) return;
|
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) => {
|
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach((id) => {
|
||||||
bindPersonnageKeyCapture(document.getElementById(id));
|
bindPersonnageKeyCapture(document.getElementById(id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ function initBuilderPanel() {
|
|||||||
if (!xEl || !wEl) return;
|
if (!xEl || !wEl) return;
|
||||||
function apply() {
|
function apply() {
|
||||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||||
const x = parseFloat(xEl.value) || 0; const
|
const x = Number.parseFloat(xEl.value) || 0; const
|
||||||
y = parseFloat(yEl.value) || 0;
|
y = Number.parseFloat(yEl.value) || 0;
|
||||||
const w = parseFloat(wEl.value) || 1; const
|
const w = Number.parseFloat(wEl.value) || 1; const
|
||||||
h = parseFloat(hEl.value) || 1;
|
h = Number.parseFloat(hEl.value) || 1;
|
||||||
if (canvasEl) {
|
if (canvasEl) {
|
||||||
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
|
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
|
||||||
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
|
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
|
||||||
@@ -109,7 +109,7 @@ function bindJaugeDefinitionSelect() {
|
|||||||
const champNomSel = document.getElementById('field-champ_nom');
|
const champNomSel = document.getElementById('field-champ_nom');
|
||||||
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
||||||
defSel.addEventListener('change', () => {
|
defSel.addEventListener('change', () => {
|
||||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
|
const info = DEFINITIONS_DATA?.[defSel.value] || { rows: [], fields: [] };
|
||||||
rowSel.innerHTML = info.rows.length
|
rowSel.innerHTML = info.rows.length
|
||||||
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
||||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
: '<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);
|
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
|
||||||
if (!fieldSels.length) return;
|
if (!fieldSels.length) return;
|
||||||
defSel.addEventListener('change', () => {
|
defSel.addEventListener('change', () => {
|
||||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
||||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
||||||
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; });
|
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
|
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
||||||
// déjà réglé plus haut dans le panneau).
|
// déjà réglé plus haut dans le panneau).
|
||||||
const defSel = document.getElementById('field-data_definition_id');
|
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
|
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
||||||
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
||||||
initFilterValuePickers();
|
initFilterValuePickers();
|
||||||
@@ -227,9 +227,9 @@ function bindClauseListDefinitionSelect(defSelId) {
|
|||||||
const defSel = document.getElementById(defSelId);
|
const defSel = document.getElementById(defSelId);
|
||||||
if (!defSel) return;
|
if (!defSel) return;
|
||||||
defSel.addEventListener('change', () => {
|
defSel.addEventListener('change', () => {
|
||||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
||||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
||||||
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) => {
|
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
|
||||||
const current = sel.value;
|
const current = sel.value;
|
||||||
sel.innerHTML = opts;
|
sel.innerHTML = opts;
|
||||||
@@ -266,8 +266,9 @@ function _filterValueDefinitionId(objName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
||||||
const info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
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 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) {
|
function updateFilterValueFromDynamic(wrap) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
function _personnageElementId() {
|
function _personnageElementId() {
|
||||||
const body = document.getElementById('personnagePanelBody');
|
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) {
|
function _savePersonnageData(data) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function _stopPersonnageIdlePreview(elementId) {
|
|||||||
|
|
||||||
function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||||
_stopPersonnageIdlePreview(elementId);
|
_stopPersonnageIdlePreview(elementId);
|
||||||
if (!frames || !frames.length) return;
|
if (!frames?.length) return;
|
||||||
targetEl.src = frames[0];
|
targetEl.src = frames[0];
|
||||||
if (frames.length === 1) return;
|
if (frames.length === 1) return;
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
@@ -42,7 +42,7 @@ function restartPersonnageIdlePreview(elementId, animations) {
|
|||||||
|
|
||||||
function startAllPersonnagePreviews() {
|
function startAllPersonnagePreviews() {
|
||||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach((targetEl) => {
|
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 animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||||
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ if (!window.__forgeFloatPanelDragBound) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
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('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
@@ -144,7 +144,7 @@ function addIconElement(iconClass) {
|
|||||||
form.method = 'post';
|
form.method = 'post';
|
||||||
form.action = ELEMENT_ADD_URL;
|
form.action = ELEMENT_ADD_URL;
|
||||||
form.innerHTML = '<input type="hidden" name="widget" value="icone">'
|
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);
|
document.body.appendChild(form);
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
@@ -314,7 +314,10 @@ if (!window.__forgeTreeDragBound) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
clearTreeDropIndicators();
|
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) => {
|
document.addEventListener('drop', (e) => {
|
||||||
@@ -456,10 +459,10 @@ function onElementMouseDown(e, id) {
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startLeft = parseFloat(el.style.left); const
|
const startLeft = Number.parseFloat(el.style.left); const
|
||||||
startTop = parseFloat(el.style.top);
|
startTop = Number.parseFloat(el.style.top);
|
||||||
const width = parseFloat(el.style.width); const
|
const width = Number.parseFloat(el.style.width); const
|
||||||
height = parseFloat(el.style.height);
|
height = Number.parseFloat(el.style.height);
|
||||||
let moved = false;
|
let moved = false;
|
||||||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
// 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
|
// 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('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
if (moved) {
|
if (moved) {
|
||||||
const newLeft = parseFloat(el.style.left); const
|
const newLeft = Number.parseFloat(el.style.left); const
|
||||||
newTop = parseFloat(el.style.top);
|
newTop = Number.parseFloat(el.style.top);
|
||||||
updatePosFields(newLeft, newTop, width, height);
|
updatePosFields(newLeft, newTop, width, height);
|
||||||
saveGeometry(id, newLeft, newTop, width, height);
|
saveGeometry(id, newLeft, newTop, width, height);
|
||||||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||||||
@@ -501,10 +504,10 @@ function onResizeMouseDown(e, id) {
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startWidth = parseFloat(el.style.width); const
|
const startWidth = Number.parseFloat(el.style.width); const
|
||||||
startHeight = parseFloat(el.style.height);
|
startHeight = Number.parseFloat(el.style.height);
|
||||||
const left = parseFloat(el.style.left); const
|
const left = Number.parseFloat(el.style.left); const
|
||||||
top = parseFloat(el.style.top);
|
top = Number.parseFloat(el.style.top);
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
const dx = ev.clientX - startX; const
|
const dx = ev.clientX - startX; const
|
||||||
@@ -517,8 +520,8 @@ function onResizeMouseDown(e, id) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
const newWidth = parseFloat(el.style.width); const
|
const newWidth = Number.parseFloat(el.style.width); const
|
||||||
newHeight = parseFloat(el.style.height);
|
newHeight = Number.parseFloat(el.style.height);
|
||||||
updatePosFields(left, top, newWidth, newHeight);
|
updatePosFields(left, top, newWidth, newHeight);
|
||||||
saveGeometry(id, left, top, newWidth, newHeight);
|
saveGeometry(id, left, top, newWidth, newHeight);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ function triggerBubbleKind(speaker) {
|
|||||||
// valeur déjà enregistrée.
|
// valeur déjà enregistrée.
|
||||||
function triggerSpeakerOptions(currentSpeaker) {
|
function triggerSpeakerOptions(currentSpeaker) {
|
||||||
const options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
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;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerEscAttr(str) {
|
function triggerEscAttr(str) {
|
||||||
return (str || '').replace(/"/g, '"');
|
return (str || '').replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
|
// ---------- 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');
|
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) {
|
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
||||||
const assetOptions = triggerAudioAssetOptions();
|
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
|
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 (
|
return (
|
||||||
'<div class="questBubbleAudioRow">'
|
'<div class="questBubbleAudioRow">'
|
||||||
@@ -101,7 +107,7 @@ function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
|||||||
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
||||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||||
const kind = triggerBubbleKind(line.speaker);
|
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 (
|
return (
|
||||||
'<div class="questBubbleRow">'
|
'<div class="questBubbleRow">'
|
||||||
+ `<div class="questBubble questBubble-${kind}">`
|
+ `<div class="questBubble questBubble-${kind}">`
|
||||||
@@ -112,7 +118,7 @@ function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
|||||||
+ '<div class="questBubbleBody">'
|
+ '<div class="questBubbleBody">'
|
||||||
+ '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." '
|
+ '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." '
|
||||||
+ 'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" '
|
+ '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)
|
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url)
|
||||||
}</div>`
|
}</div>`
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -168,7 +174,7 @@ function triggerQuestionBubbleHtml(dialogueId, line, index) {
|
|||||||
function triggerVariableActionLabel(item) {
|
function triggerVariableActionLabel(item) {
|
||||||
const opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
|
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}` : '';
|
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
|
// Feuille "Modifier une variable" — un encart compact (même largeur que
|
||||||
@@ -201,7 +207,7 @@ const COLLISION_LEAF_PILL_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function triggerItemLeafId(item) {
|
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)
|
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
|
||||||
@@ -264,7 +270,7 @@ function triggerLeafPillHtml(item, target, targetId, index, total) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerAssetFilename(url) {
|
function triggerAssetFilename(url) {
|
||||||
return ((url || '').split('/').pop() || url || '').replace(/</g, '<');
|
return ((url || '').split('/').pop() || url || '').replaceAll('<', '<');
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerGenericLeafBoxHtml(title, text) {
|
function triggerGenericLeafBoxHtml(title, text) {
|
||||||
@@ -295,7 +301,7 @@ function triggerLeafBoxHtml(item) {
|
|||||||
return triggerGenericLeafBoxHtml('Vidéo', `${triggerAssetFilename(item.asset_url)} — ${mLabel}`);
|
return triggerGenericLeafBoxHtml('Vidéo', `${triggerAssetFilename(item.asset_url)} — ${mLabel}`);
|
||||||
}
|
}
|
||||||
if (item.type === 'indication') {
|
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') {
|
if (item.type === 'attendre') {
|
||||||
return triggerGenericLeafBoxHtml('Attendre', `${item.data_value || '0'} seconde(s)`);
|
return triggerGenericLeafBoxHtml('Attendre', `${item.data_value || '0'} seconde(s)`);
|
||||||
@@ -328,7 +334,7 @@ function triggerAppendButtonHtml(target, targetId, afterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerActionChainHtml(chain, target, targetId) {
|
function triggerActionChainHtml(chain, target, targetId) {
|
||||||
if (!chain || !chain.length) return '';
|
if (!chain?.length) return '';
|
||||||
return chain.map((item, i) => {
|
return chain.map((item, i) => {
|
||||||
const leafId = triggerItemLeafId(item);
|
const leafId = triggerItemLeafId(item);
|
||||||
return (
|
return (
|
||||||
@@ -383,14 +389,14 @@ const _triggerCollapsed = {};
|
|||||||
let _triggerActiveDialogueId = null;
|
let _triggerActiveDialogueId = null;
|
||||||
|
|
||||||
function _chainLeafIds(chain) {
|
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) {
|
function _triggerLeafIds(trigger) {
|
||||||
if (trigger.condition) {
|
if (trigger.condition) {
|
||||||
const vrai = trigger.condition.si_vrai; const
|
const vrai = trigger.condition.si_vrai; const
|
||||||
faux = trigger.condition.si_faux;
|
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);
|
return _chainLeafIds(trigger.chain);
|
||||||
}
|
}
|
||||||
@@ -398,10 +404,11 @@ function _triggerLeafIds(trigger) {
|
|||||||
function triggerChainHtml(trigger) {
|
function triggerChainHtml(trigger) {
|
||||||
const isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
const isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
||||||
const leafIds = _triggerLeafIds(trigger);
|
const leafIds = _triggerLeafIds(trigger);
|
||||||
const isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
|
const isActive = _triggerActiveDialogueId && leafIds.includes(_triggerActiveDialogueId);
|
||||||
const cardOnclick = `triggerClickObjectCard(${trigger.object_id}, ${leafIds[0] ? `'${leafIds[0]}'` : 'null'})`;
|
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)
|
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>'
|
+ '<span class="questTriggerCollapseChevron">▾</span>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
@@ -465,7 +472,7 @@ function triggerCondArrowHtml(kind, label) {
|
|||||||
|
|
||||||
function triggerConditionColumnHtml(condition, target, targetId) {
|
function triggerConditionColumnHtml(condition, target, targetId) {
|
||||||
const opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
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 branchesHtml = ['si_vrai', 'si_faux'].map((branchKey) => {
|
||||||
const kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
const kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
||||||
const arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
const arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||||
@@ -520,7 +527,7 @@ function _registerChainDialogues(chain, owner, dialogueIds) {
|
|||||||
// l'autre.
|
// l'autre.
|
||||||
function _clearRegistryEntriesWithKey(key) {
|
function _clearRegistryEntriesWithKey(key) {
|
||||||
Object.keys(_triggerDialogueRegistry).forEach((id) => {
|
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
|
// 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
|
// garde (cliquer sur une autre carte reste possible, voir
|
||||||
// triggerClickObjectCard).
|
// triggerClickObjectCard).
|
||||||
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
|
if (!dialogueIds.includes(_triggerActiveDialogueId)) {
|
||||||
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
|
_triggerActiveDialogueId = dialogueIds.at(-1) || null;
|
||||||
}
|
}
|
||||||
triggerRenderSlot();
|
triggerRenderSlot();
|
||||||
});
|
});
|
||||||
@@ -635,11 +642,14 @@ function triggerInlineAddQuestion(dialogueId) {
|
|||||||
triggerInlineSaveDialogue(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) {
|
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
|
||||||
const entry = _triggerDialogueRegistry[dialogueId];
|
triggerInlineUpdateBubbleText(dialogueId, index, text);
|
||||||
if (!entry) return;
|
|
||||||
entry.dialogueRef.lines[index].text = text;
|
|
||||||
triggerInlineSaveDialogue(dialogueId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
|
// 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];
|
const entry = _triggerDialogueRegistry[dialogueId];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const line = entry.dialogueRef.lines[index];
|
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.push('');
|
||||||
while (line.choices.length > count) line.choices.pop();
|
while (line.choices.length > count) line.choices.pop();
|
||||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
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) {
|
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
|
||||||
const entry = _triggerDialogueRegistry[dialogueId];
|
const entry = _triggerDialogueRegistry[dialogueId];
|
||||||
if (!entry) return;
|
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);
|
triggerInlineSaveDialogue(dialogueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +765,7 @@ function triggerRenderObjectStep() {
|
|||||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
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})">`
|
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>`
|
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
||||||
+ `<span>${(o.name || '').replace(/</g, '<')}</span>`
|
+ `<span>${(o.name || '').replaceAll('<', '<')}</span>`
|
||||||
+ '</button>').join('');
|
+ '</button>').join('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
var fieldsList = document.getElementById('fieldsList');
|
var fieldsList = document.getElementById('fieldsList');
|
||||||
var template = document.getElementById('fieldRowTemplate');
|
var template = document.getElementById('fieldRowTemplate');
|
||||||
var btnAddField = document.getElementById('btnAddField');
|
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) {
|
function wireRow(row) {
|
||||||
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
|
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
|
||||||
@@ -29,14 +34,9 @@
|
|||||||
var boundsTargets = row.querySelectorAll('.boundsTarget');
|
var boundsTargets = row.querySelectorAll('.boundsTarget');
|
||||||
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
|
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
|
||||||
|
|
||||||
function toggle(target, placeholder, visible) {
|
|
||||||
target.style.display = visible ? '' : 'none';
|
|
||||||
placeholder.style.display = visible ? 'none' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncFieldTypeVisibility() {
|
function syncFieldTypeVisibility() {
|
||||||
var isRelation = typeSelect.value === 'relation';
|
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);
|
toggle(relationTarget, relationPlaceholder, isRelation);
|
||||||
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
|
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user