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