Compare commits
5
Commits
8e6c176072
...
3260cde122
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3260cde122 | ||
|
|
ccf836f2c5 | ||
|
|
66d8eaeae8 | ||
|
|
55e81c0fbb | ||
|
|
20f9398bd4 |
@@ -19,11 +19,10 @@ on:
|
|||||||
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
|
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
|
||||||
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
||||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
|
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
|
||||||
# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première
|
# lui, reste NON-BLOQUANT (continue-on-error) — remis en place le
|
||||||
# période — voir CODE_QUALITY.md pour la trajectoire vers un mode
|
# 18/09/2026 une fois l'instance prod opérationnelle (voir CODE_QUALITY.md
|
||||||
# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt
|
# pour l'historique du retrait temporaire), le temps que le rapport de
|
||||||
# que de bloquer tout de suite sur des centaines de signalements pas
|
# code smells soit entièrement trié.
|
||||||
# encore triés.
|
|
||||||
|
|
||||||
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
||||||
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
||||||
@@ -92,7 +91,7 @@ jobs:
|
|||||||
COPY static/js/play/ static/js/play/
|
COPY static/js/play/ static/js/play/
|
||||||
COPY static/js/scenes/ static/js/scenes/
|
COPY static/js/scenes/ static/js/scenes/
|
||||||
COPY static/js/triggers/ static/js/triggers/
|
COPY static/js/triggers/ static/js/triggers/
|
||||||
RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
RUN node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
||||||
DOCKERFILE
|
DOCKERFILE
|
||||||
|
|
||||||
lint-python:
|
lint-python:
|
||||||
@@ -205,8 +204,19 @@ jobs:
|
|||||||
cd "${{ secrets.DEPLOY_PATH }}"
|
cd "${{ secrets.DEPLOY_PATH }}"
|
||||||
echo "REGISTRY_IMAGE=${{ secrets.REGISTRY_IMAGE }}" > .env
|
echo "REGISTRY_IMAGE=${{ secrets.REGISTRY_IMAGE }}" > .env
|
||||||
echo "IMAGE_TAG=${{ gitea.sha }}" >> .env
|
echo "IMAGE_TAG=${{ gitea.sha }}" >> .env
|
||||||
docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}"
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
'
|
'
|
||||||
|
|
||||||
|
- name: Nettoyage de la cle SSH
|
||||||
|
# if: always() - meme si les etapes precedentes ont echoue, la cle
|
||||||
|
# privee posee ci-dessus (~/.ssh/deploy_key) ne doit jamais rester
|
||||||
|
# sur le disque du job. Le runner semble deja jetable (voir
|
||||||
|
# "docker volume rm .../JOB-<id>-..." dans les logs des autres
|
||||||
|
# jobs de ce meme workflow), mais jamais verifie directement pour
|
||||||
|
# CE job (jamais execute sur dev, reserve a main) - ne pas se fier
|
||||||
|
# uniquement a une inference par analogie pour une cle privee.
|
||||||
|
if: always()
|
||||||
|
run: rm -f ~/.ssh/deploy_key
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
## Stack et structure
|
||||||
|
Bref rappel : Python/Flask/Jinja backend, JS/HTML/CSS frontend, architecture en couches (routes → services/screens → db, voir contrat import-linter dans pyproject.toml).
|
||||||
|
|
||||||
|
## Règle absolue : qualité de code non négociable
|
||||||
|
- Tout code Python doit passer mypy --strict sans erreur. Jamais de # type: ignore sans commentaire justifiant précisément pourquoi (pas de vague, une vraie raison technique).
|
||||||
|
- Tout code doit passer Ruff, Bandit, Vulture, import-linter, ESLint, Stylelint sans nouvelle erreur avant d'être considéré terminé.
|
||||||
|
- Avant de committer, lance systématiquement pre-commit run --all-files et corrige tout ce qui échoue — jamais de --no-verify ou de SKIP= sans me le signaler et justifier explicitement pourquoi.
|
||||||
|
- Aucune règle de lint/typage ne doit être désactivée globalement dans un fichier de config sans ma validation explicite. Une exception ponctuelle (# noqa, # nosec, // NOSONAR, ts-ignore) doit toujours être sur la ligne concernée avec un commentaire expliquant précisément pourquoi, jamais un ignore de fichier entier ou de règle globale.
|
||||||
|
|
||||||
|
## Distinguer bug réel vs faux positif
|
||||||
|
Avant de corriger un finding d'un outil (Mypy, Bandit, Sonar, etc.), détermine s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (ex. injection de fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur). Ne corrige jamais mécaniquement sans comprendre la cause. Si un correctif change un comportement (pas juste une annotation/un style), signale-le explicitement avant de l'appliquer et explique pourquoi.
|
||||||
|
|
||||||
|
## Tests unitaires — non-régression
|
||||||
|
- Toute nouvelle fonctionnalité ou tout correctif de bug doit être accompagné d'un test qui aurait échoué avant le fix et passe après.
|
||||||
|
- Ne jamais me dire "les tests passent" comme preuve suffisante pour un changement de sécurité ou de comportement sensible (ex. échappement de données, gestion de permissions) — écris un test ou un script de vérification dédié qui prouve concrètement le comportement attendu (voir l'exemple du fix XSS json_for_script : test avec une charge malveillante réelle, pas juste "aucune régression").
|
||||||
|
- Ne réduis jamais la portée d'une assertion de test pour la faire passer sans discussion préalable avec moi.
|
||||||
|
- Signale immédiatement toute fuite d'état entre tests (fixtures partagées, données non nettoyées) même si elle n'est pas dans le scope de la tâche en cours.
|
||||||
|
|
||||||
|
## Code mort
|
||||||
|
Avant de supprimer une fonction jugée "morte" par un outil (Vulture, ESLint no-unused-vars), vérifie par grep exhaustif (imports directs, dispatch dynamique par nom de chaîne, référencé depuis un template Jinja ou un script JS, appel getattr/introspection) avant de conclure qu'elle est vraiment inutilisée.
|
||||||
|
|
||||||
|
## SonarQube local (développement continu)
|
||||||
|
En plus de l'instance CI (sonar.forgebase.fr, non-bloquante), une instance SonarQube locale doit tourner via Docker pour un usage rapide pendant le développement, séparée de la CI.
|
||||||
|
|
||||||
|
### Mise en place (à faire une fois si l'instance n'existe pas encore)
|
||||||
|
Si aucune instance locale n'est déjà en place : lance une instance SonarQube Community via Docker (image officielle, avec une base PostgreSQL plutôt que la H2 embarquée par défaut — voir la mise en place déjà documentée pour sonar.forgebase.fr comme référence). Communique-moi clairement l'adresse locale (ex. http://localhost:9000) une fois lancée, et le port utilisé, pour que je puisse ouvrir le dashboard moi-même à tout moment.
|
||||||
|
|
||||||
|
### Utilisation continue pendant le développement
|
||||||
|
- Avant de considérer une tâche terminée (nouvelle fonctionnalité, gros refactor, fix de bug), lance une analyse sonar-scanner contre cette instance locale.
|
||||||
|
- Si des erreurs ou vulnérabilités sont détectées sur du code NOUVEAU (écrit dans la session en cours) : corrige-les toi-même directement, comme pour Ruff/Mypy/Bandit — pas besoin de me demander la permission pour un vrai bug évident sur du code que tu viens d'écrire.
|
||||||
|
- Si l'analyse détecte quelque chose sur du code existant (pas modifié dans la session en cours) : signale-le-moi, n'y touche pas sans validation, même logique que pour le rapport SonarQube de la Phase 3 (analyse d'abord, décision ensemble avant correction).
|
||||||
|
- Distingue toujours dans ton compte-rendu : ce qui a été corrigé automatiquement (code neuf, évident) vs ce qui est signalé en attente de décision (code existant, ou correction ambiguë).
|
||||||
|
- Donne-moi régulièrement l'adresse du dashboard local si je veux consulter moi-même le détail visuellement.
|
||||||
|
|
||||||
|
## Convention vs bug de config
|
||||||
|
Si un preset de lint (ex. airbnb-base, stylelint-config-standard) contredit une convention cohérente déjà établie dans le code (ex. camelCase CSS, var au lieu de let/const), n'impose pas de réécriture mécanique du code : propose d'adapter la config, documente pourquoi, et attends ma validation.
|
||||||
|
|
||||||
|
## Discipline de communication
|
||||||
|
- Pour tout lot de travail dépassant quelques fichiers, découpe en étapes vérifiables (diff + tests à chaque étape), jamais un correctif massif d'un coup.
|
||||||
|
- Signale tout changement de comportement réel séparément du reste (pas noyé dans un rapport de style).
|
||||||
|
- Si un choix a plusieurs options légitimes (garder/typer/supprimer, corriger/documenter une exception), présente les options avec leurs compromis plutôt que de trancher seul.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
Toute règle désactivée, tout # nosec/NOSONAR, toute adaptation de config doit être répercutée dans CODE_QUALITY.md.
|
||||||
|
|
||||||
|
## Organisation des fichiers (applicable à partir de maintenant, pas de refactor rétroactif)
|
||||||
|
|
||||||
|
- 1 fichier = 1 fonction publique. Les fonctions privées d'appui (préfixées _) utilisées uniquement par cette fonction restent dans le même fichier.
|
||||||
|
- 1 dossier = 1 responsabilité claire.
|
||||||
|
- Chaque dossier a un fichier barrel (__init__.py) qui importe/exporte toutes les fonctions publiques du dossier (pattern déjà en place dans db/__init__.py et screens/__init__.py — garde __all__ explicite à jour à chaque ajout).
|
||||||
|
- Chaque dossier a un fichier .md qui documente chaque fonction publique qu'il contient : signature, rôle, paramètres, valeur de retour, exceptions possibles. Mets-le à jour à chaque fonction ajoutée/modifiée/supprimée.
|
||||||
|
|
||||||
|
### Exceptions à cette règle (ne pas séparer)
|
||||||
|
- Le pattern sanitize_X / resolve_X (validation + résolution avec repli sur valeur par défaut) : ces deux fonctions restent dans le même fichier quand resolve_X appelle directement sanitize_X et qu'elles partagent des constantes de configuration. Ce sont deux étapes d'un même contrat, pas deux responsabilités séparées.
|
||||||
|
- Un groupe de fonctions fortement couplées par une table de dispatch centrale et des constantes de module partagées (ex. collision_rules.py) peut rester dans un seul fichier si les séparer forcerait soit une duplication de constantes, soit un fichier de constantes partagé importé par tous les autres pour un gain de lisibilité douteux. En cas de doute, demande avant de trancher plutôt que d'appliquer la règle mécaniquement.
|
||||||
|
- Des handlers Flask co-localisés par convention d'URL (ex. plusieurs routes d'un même sous-domaine fonctionnel) qui n'ont aucun appel ni état partagé entre eux ne sont PAS un cas d'exception — ceux-là doivent être séparés, un fichier par route/fonction.
|
||||||
|
|
||||||
|
### Avant de créer une nouvelle fonction
|
||||||
|
Vérifie si elle appartient à un fichier existant à forte cohésion (voir exceptions ci-dessus) ou si elle mérite son propre fichier. En cas de doute sur la classification, demande plutôt que de deviner.
|
||||||
|
|
||||||
|
### Constantes partagées entre plusieurs fonctions d'un même dossier
|
||||||
|
Si plusieurs fonctions séparées (dans des fichiers différents) ont besoin des mêmes constantes, crée un fichier constants.py dans le dossier concerné plutôt que de dupliquer les valeurs — ne duplique jamais une constante de configuration entre fichiers.
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
# Qualité de code — Forge Engine
|
||||||
|
|
||||||
|
Référence de fonctionnement du dispositif qualité mis en place (Phases 1
|
||||||
|
à 4). Pour le compte-rendu chronologique de ce qui a été fait/trouvé/
|
||||||
|
décidé pendant la mise en place, voir `docs/SESSION_RECAP.md` — ce
|
||||||
|
fichier-ci documente uniquement **l'état actuel** et **comment
|
||||||
|
l'utiliser au quotidien**.
|
||||||
|
|
||||||
|
Règle de base, non négociable : **aucune règle de lint/typage n'est
|
||||||
|
désactivée globalement dans un fichier de config sans validation
|
||||||
|
explicite.** Une exception ponctuelle est toujours une ligne de code
|
||||||
|
(`# noqa`, `# nosec`, `// NOSONAR`) avec une raison précise, jamais un
|
||||||
|
ignore de fichier entier ou de règle globale — voir section 4.
|
||||||
|
|
||||||
|
## 1. Vue d'ensemble
|
||||||
|
|
||||||
|
| Outil | Vérifie | Portée | Obligatoire / Avertissement |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) |
|
||||||
|
| **Ruff** (format) | Formatage Python | `*.py` | Obligatoire |
|
||||||
|
| **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire |
|
||||||
|
| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `screens`, `scripts`, `app.py`, `build_css.py` | Obligatoire |
|
||||||
|
| **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire |
|
||||||
|
| **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire |
|
||||||
|
| **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire |
|
||||||
|
| **Stylelint** (`stylelint-config-standard`) | Lint CSS | `styles/**/*.css` | Obligatoire |
|
||||||
|
| **djLint** | Lint des templates Jinja | `templates/**/*.html` | **Avertissement** — H021 (styles inline) en backlog assumé, voir section 6 |
|
||||||
|
| **SonarQube** (local + CI) | Bugs/vulnérabilités/code smells/hotspots agrégés | tout le dépôt | **Avertissement** — non-bloquant en CI pour l'instant, voir section 3 |
|
||||||
|
|
||||||
|
## 2. Configuration de chaque outil
|
||||||
|
|
||||||
|
### Ruff (`pyproject.toml`, `[tool.ruff]`)
|
||||||
|
- `line-length = 120`, `target-version = "py313"`.
|
||||||
|
- Règles activées : `F` (pyflakes), `E`/`W` (pycodestyle), `B` (bugbear), `C4` (comprehensions), `SIM` (simplification), `S` (bandit-équivalent Ruff), `I` (imports).
|
||||||
|
- `per-file-ignores` : `S101`/`S106` désactivées pour `tests/` uniquement (assert et mots de passe en dur sont la norme dans les tests, jamais en dehors).
|
||||||
|
- Exclusions : `projects/`, `user_assets/`, `data/`, `Bug/`, `regles/` (données runtime, jamais du code).
|
||||||
|
|
||||||
|
### Mypy (`pyproject.toml`, `[tool.mypy]`)
|
||||||
|
- `strict = true`, `python_version = "3.13"`.
|
||||||
|
- `explicit_package_bases = true` + `mypy_path = "."` : nécessaire car `tests/` n'a pas de `__init__.py` — sans ça mypy refuse de démarrer ("Source file found twice under different module names"). **Ce n'est pas un assouplissement de `strict`**, juste la résolution des chemins de module.
|
||||||
|
- Mêmes exclusions que Ruff.
|
||||||
|
|
||||||
|
### Bandit (`pyproject.toml`, `[tool.bandit]`)
|
||||||
|
- `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque).
|
||||||
|
- Portée explicite (`-r ai auth core db filters publish routes screens scripts app.py build_css.py`) plutôt que tout le dépôt.
|
||||||
|
|
||||||
|
### Vulture (`pyproject.toml`, `[tool.vulture]`)
|
||||||
|
- `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`.
|
||||||
|
- `exclude = ["*/tests/*"]`.
|
||||||
|
- Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement.
|
||||||
|
|
||||||
|
### import-linter (`pyproject.toml`, `[tool.importlinter]`)
|
||||||
|
- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `screens` → `auth | filters` → `db`.
|
||||||
|
- Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau.
|
||||||
|
- `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse.
|
||||||
|
|
||||||
|
### ESLint (`.eslintrc.json`)
|
||||||
|
- Base `airbnb-base` + plugins `unused-imports`, `unicorn`.
|
||||||
|
- `max-len` porté à 120 (aligné sur Ruff) au lieu du 80 par défaut d'Airbnb.
|
||||||
|
- Plusieurs règles Airbnb désactivées **après vérification individuelle que le code existant les respecte déjà différemment** (voir section 5 pour le détail et le raisonnement de chacune) : `no-param-reassign`, `no-use-before-define` (assoupli pour les fonctions, gardé strict pour variables/classes), `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat`.
|
||||||
|
- `unused-imports/no-unused-vars` avec une liste blanche (`varsIgnorePattern`) d'environ 95 noms : fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja, invisibles pour l'analyse statique d'ESLint.
|
||||||
|
- Bloc `globals` documentant les variables cross-fichiers volontaires (ex. `gameData`, `screensData` — voir section 5).
|
||||||
|
- **`eslint-plugin-unicorn`** (version `55.0.0` épinglée — la dernière exige ESLint ≥10, incompatible avec notre `^8.57.1`) : activé avec **UNIQUEMENT 9 règles explicitement listées**, jamais sa config `recommended` complète (qui en contient des dizaines d'autres, jamais évaluées pour ce projet — décision délibérée pour ne pas introduire un nouveau volume de règles non passées en revue). Choisi pour nettoyer le lot "modernisation JS" du rapport SonarQube (voir `docs/JS_MODERNIZATION_PLAN.md`) : `eslint-plugin-sonarjs` (l'équivalent officiel SonarSource) ne couvrait qu'une seule des règles visées. Les 9 règles activées, chacune avec un fixer `--fix` vérifié :
|
||||||
|
- `unicorn/prefer-number-properties` — `parseFloat`/`parseInt`/`isNaN`/`isFinite` → `Number.*`
|
||||||
|
- `unicorn/prefer-string-replace-all` — `.replace(/x/g, ...)` → `.replaceAll(...)`
|
||||||
|
- `unicorn/prefer-dom-node-dataset` — `getAttribute('data-x')` → `.dataset.x`
|
||||||
|
- `unicorn/prefer-includes` — `.indexOf(x) !== -1` → `.includes(x)`
|
||||||
|
- `unicorn/prefer-string-starts-ends-with` — comparaison manuelle de sous-chaîne → `.startsWith()`/`.endsWith()`
|
||||||
|
- `unicorn/prefer-modern-math-apis` — expression mathématique manuelle → `Math.hypot()` etc.
|
||||||
|
- `unicorn/prefer-at` — `arr[arr.length - 1]` → `arr.at(-1)`
|
||||||
|
- `unicorn/no-useless-fallback-in-spread` — `{...(x || {})}` → `{...x}`
|
||||||
|
- `unicorn/no-for-loop` — boucle `for` classique sur un itérable → `for...of`
|
||||||
|
|
||||||
|
### Stylelint (`.stylelintrc.json`)
|
||||||
|
- Base `stylelint-config-standard`.
|
||||||
|
- `selector-class-pattern`/`selector-id-pattern` désactivées : le projet utilise du camelCase pour ses classes/id CSS depuis le début (`.canvasElement`, `#scormProgress`...) — imposer le kebab-case du preset aurait demandé de renommer des milliers de sélecteurs et leurs usages JS/HTML pour un gain nul.
|
||||||
|
- `no-descending-specificity` désactivée : le CSS existant est organisé par composant/fonctionnalité, pas par ordre strict de spécificité.
|
||||||
|
- `ignoreFiles` : `static/vendor/**`, `static/style.css` (bundle généré, contient du Bulma vendored).
|
||||||
|
|
||||||
|
### djLint (`pyproject.toml`, `[tool.djlint]`)
|
||||||
|
- `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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Un outil a la fois (memes commandes que .pre-commit-config.yaml)
|
||||||
|
ruff check .
|
||||||
|
ruff format --check .
|
||||||
|
mypy .
|
||||||
|
vulture
|
||||||
|
bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
|
||||||
|
lint-imports
|
||||||
|
djlint templates
|
||||||
|
npx eslint "static/js/**/*.js"
|
||||||
|
npx stylelint "styles/**/*.css"
|
||||||
|
|
||||||
|
# Tout d'un coup (ce que fait un commit)
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
# Suite de tests
|
||||||
|
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 : 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
|
||||||
|
|
||||||
|
1. **Jamais de correction mécanique sans comprendre la cause.** Avant de corriger un finding, déterminer s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur, architecture volontaire).
|
||||||
|
2. **Une exception ponctuelle, jamais globale.** Le commentaire de suppression va **sur la ligne exacte** signalée par l'outil — pas la ligne au-dessus, pas la ligne en dessous (piège vécu à plusieurs reprises cette session : un commentaire mal placé ne supprime rien du tout, silencieusement).
|
||||||
|
3. **La raison est précise, jamais vague.** "Faux positif" seul ne suffit pas — expliquer *pourquoi* (ex. "table_name vient de slugify()+prefixe obj_, jamais d'une entrée brute").
|
||||||
|
4. **Format attendu par outil** (vérifié empiriquement cette session, chaque outil a ses propres exigences de position du mot-clé) :
|
||||||
|
- Bandit : `# nosec <CODE>` — le mot `nosec` doit être immédiatement précédé d'un `#` sur la ligne.
|
||||||
|
- Ruff : `# noqa: <CODE>` — le mot `noqa` doit être immédiatement précédé d'un `#` sur la ligne. **Bandit et Ruff peuvent coexister sur une même ligne physique** en utilisant deux `#` distincts (`# nosec B101 # noqa: S101 - raison`) — c'est structurel, pas un choix de style : chaque outil cherche son propre mot-clé juste après un `#`, et aucun format à un seul `#` ne peut satisfaire les deux à la fois (testé empiriquement).
|
||||||
|
- SonarQube (Python) : `# NOSONAR <règle> - raison`, sur la ligne exacte.
|
||||||
|
- SonarQube (JS) : `// NOSONAR <règle> - raison`, sur la ligne exacte.
|
||||||
|
- SonarQube (templates Jinja/HTML, règle `Web:*`) : **aucune syntaxe trouvée qui fonctionne** malgré plusieurs tentatives (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) — voir la limitation documentée en section 5 (`Web:S5247`).
|
||||||
|
5. **Toute exception validée est répercutée ici** (section 5), avec la date/le contexte. Une exception non documentée ici n'est pas considérée comme validée.
|
||||||
|
6. **Qui valide** : aucune exception n'est appliquée sans validation explicite de l'utilisateur — présenter le choix avec ses compromis, jamais trancher seul quand plusieurs options légitimes existent.
|
||||||
|
|
||||||
|
## 5. Exceptions et faux positifs documentés
|
||||||
|
|
||||||
|
| Site(s) | Outil / règle | Raison | Contexte |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
||||||
|
| `screens/data_actions/compute_operation.py` (×2), `static/js/play/offline/compute-operation.js` (×2), `static/js/scenes/collision-rules-editor.js` (×2), `static/js/triggers/trigger-editor.js` | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI) — jamais un usage cryptographique. | Phase 3 |
|
||||||
|
| `static/js/play/offline/xapi-client.js` (18 sites) + `static/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
|
||||||
|
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
|
||||||
|
| `scripts/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
|
||||||
|
| `static/js/scenes/collision-rules-editor.js` (`leafAction.then = nextLeaf`) | `javascript:S7739` | `then` est un champ métier ("action suivante de la chaîne"), jamais une promesse — vérifié qu'aucun site d'appel ne le passe à `await`/`Promise.resolve()`. Risque latent documenté plutôt que renommage (le nom est ancré dans le schéma JSON persisté en base et côté Python). | Phase 3 |
|
||||||
|
| `db/assert_not_none.py:14` | `B101`/`S101`/`python:S7632` | Unique `assert` de narrowing de type restant dans tout le moteur, après centralisation de 11 sites dispersés (`ai/`, `routes/scenes/`, `screens/payload/`, `scripts/`) dans ce helper unique. Voir section 4 pour l'impossibilité structurelle de satisfaire Bandit+Ruff avec un seul `#`. | Session du 16/09/2026 |
|
||||||
|
| `db/rows/delete_row.py`, `db/rows/get_row.py`, `db/rows/list_rows.py`, `screens/animations/update_animation_clip.py`, `screens/flow/add_flow_node.py`, `screens/scenes/add_scene_object.py` | `B608`/`S608`/`python:S7632` | Noms de table/colonnes construits uniquement à partir de `slugify()`/whitelists codées en dur (`_UPDATABLE_FIELDS`, `FLOW_NODE_FIELDS`), jamais d'une entrée arbitraire — valeurs toujours paramétrées (`?`). Distinct du cas `assert_not_none` ci-dessus (nature différente : construction de SQL, pas narrowing de type) — non couvert par ce refactor. | Session du 16/09/2026 |
|
||||||
|
| `static/js/play/bindings.js:179` (`gameData = newData`) | `javascript:S2703` | Pattern volontaire de scripts globaux (pas des modules ES) : `gameData`/`screensData` sont déclarés une fois par `let` dans le `<script>` inline de `templates/play.html`, chargé avant tous les `static/js/play/*.js` (ordre séquentiel vérifié, pas de `defer`/`async`). Réassignation légitime d'une variable déjà déclarée dans un scope global partagé — déjà documenté dans `.eslintrc.json` (`"gameData": "writable"`). | Session du 16/09/2026 |
|
||||||
|
| `static/js/play/bindings.js:180` (`screensData = gameData.screens`) | `javascript:S2703` | Même bloc, même pattern, même fichier de déclaration que `gameData` ci-dessus : `let screensData = gameData.screens;` posé dans le même `<script>` inline de `templates/play.html:145` (juste après `gameData:144`), chargé avant `bindings.js` — déjà documenté dans `.eslintrc.json` (`"screensData": "writable"`). Lot 2 "modernisation JS". | Lot 2 "modernisation JS", 16/09/2026 |
|
||||||
|
| `static/js/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 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"
|
||||||
|
|
||||||
|
Méthode : pour chaque regex distincte flaguée, construction d'entrées **adversariales** (choisies pour maximiser l'ambiguïté que Sonar soupçonne) via un script Node.js dédié, mesure du temps d'exécution à plusieurs tailles croissantes. Un vrai ReDoS montre une croissance **exponentielle** du temps avec la taille de l'entrée (doubler la taille multiplie le temps par un facteur, pas par une constante) — ici, le temps reste quasi-linéaire à toutes les tailles testées, y compris pour la structure la plus suspecte des 3.
|
||||||
|
|
||||||
|
**Pattern A** — `FORGE_REF_PATTERN` (`filter-repeater-rows.js:10`) et `_FILTER_REF_RE` (`panel-init.js:254`, copie identique) :
|
||||||
|
```
|
||||||
|
/^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/
|
||||||
|
```
|
||||||
|
Structure soupçonnée : deux groupes quantifiés (`[^.{}]+`) séparés par un littéral (`\.`). Non exploitable ici car les deux classes sont **négatives** et excluent explicitement le `.` qui les sépare — à une position donnée, il n'existe qu'un seul découpage possible entre les deux groupes (pas de chevauchement combinatoire).
|
||||||
|
Entrées testées : `` `{{` + 'a'.repeat(n) `` (pas de fermeture) et `` `{{` + 'a'.repeat(n) + '.' + 'b'.repeat(n) `` (point présent, pas de fermeture), `n` = 1 000 / 10 000 / 50 000 / 100 000.
|
||||||
|
Résultat mesuré : **1.39 ms à n=100 000** (pire cas). Vérifié aussi que le pattern reste correct sur une entrée valide (`{{Objet.champ}}` → capture `['Objet', 'champ']`).
|
||||||
|
|
||||||
|
**Pattern B** — `FORGE_VAR_REF_PATTERN` (`filter-repeater-rows.js:11`) et `_VAR_REF_RE` (`panel-init.js:261`, copie identique) :
|
||||||
|
```
|
||||||
|
/^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/
|
||||||
|
```
|
||||||
|
Structure soupçonnée : la plus proche d'un vrai ReDoS des 3 — un groupe répété par `*` dont une branche de l'alternance contient elle-même un `+` (proche du classique `(a+)*`). Non exploitable ici car chaque itération exige un caractère de tête exclusif (`.` ou `[`) qui est justement exclu de la classe négative interne (`[^.{}[\]]`) — aucune itération ne peut chevaucher la suivante.
|
||||||
|
Entrées testées : `` `{{$a` + '.b'.repeat(n) `` (répétition simple, pas de fermeture) et `` `{{$a` + '.b[0]'.repeat(n) `` (alternance des deux branches, pas de fermeture), `n` = 1 000 / 5 000 / 10 000 / 20 000.
|
||||||
|
Résultat mesuré : **0.95 ms à n=20 000 segments** (pire cas, forme alternée). Vérifié sur entrée valide (`{{$var.champ[0].sous}}` → capture `['var', '.champ[0].sous']`).
|
||||||
|
|
||||||
|
**Pattern C** — `xapi-client.js:132` :
|
||||||
|
```js
|
||||||
|
config.endpoint.replace(/\/+$/, '')
|
||||||
|
```
|
||||||
|
Structure soupçonnée : un seul groupe quantifié sur un littéral unique, ancré en fin de chaîne — le cas le plus simple des 3, sans groupe adjacent ni alternance avec qui entrer en ambiguïté.
|
||||||
|
Entrée testée : `'/'.repeat(n)`, `n` = 10 000 / 100 000 / 1 000 000.
|
||||||
|
Résultat mesuré : **1.38 ms à n=1 000 000** (le run à n=100 000 a affiché 13.89 ms, un pic de bruit de mesure — non reproductible et incohérent avec un temps plus court à n=1 000 000, donc pas un signal réel). Vérifié sur entrée valide (`'https://host///'.replace(...)` → `'https://host'`).
|
||||||
|
|
||||||
|
**Pour refaire ce test après une modification d'une de ces 3 regex** : `node docs/redos_probe_s8786.js` — script conservé dans le dépôt, directement exécutable, pas à reconstituer depuis la prose. Si le temps croît plus vite que linéairement (ex. ×100 quand la taille ×10), c'est un vrai ReDoS — sinon, le `NOSONAR` reste justifié.
|
||||||
|
|
||||||
|
## 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** — 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`.
|
||||||
+1
-3
@@ -327,9 +327,7 @@ def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int,
|
|||||||
)
|
)
|
||||||
messages.append({"role": "user", "content": tool_results})
|
messages.append({"role": "user", "content": tool_results})
|
||||||
|
|
||||||
assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
response = db.assert_not_none(response, "_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois")
|
||||||
"_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois"
|
|
||||||
)
|
|
||||||
text = next((b.text for b in response.content if b.type == "text"), "")
|
text = next((b.text for b in response.content if b.type == "text"), "")
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
|
|||||||
+1
-3
@@ -558,9 +558,7 @@ def _dispatch_add_scene_object(
|
|||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
)
|
)
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id vient d'etre cree par screens.add_scene_object juste au-dessus")
|
||||||
"object_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
|
||||||
)
|
|
||||||
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
||||||
# posée par screens.add_scene_object — reste passée par le même
|
# posée par screens.add_scene_object — reste passée par le même
|
||||||
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
|
|||||||
vrai INSERT dans cette table.
|
vrai INSERT dans cette table.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .assert_not_none import assert_not_none
|
||||||
from .connection import connect
|
from .connection import connect
|
||||||
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
||||||
from .custom_events.create_custom_event import create_custom_event
|
from .custom_events.create_custom_event import create_custom_event
|
||||||
@@ -90,6 +91,7 @@ __all__ = [
|
|||||||
"slugify",
|
"slugify",
|
||||||
"table_name_for",
|
"table_name_for",
|
||||||
"json_for_script",
|
"json_for_script",
|
||||||
|
"assert_not_none",
|
||||||
"game_dir",
|
"game_dir",
|
||||||
"db_path",
|
"db_path",
|
||||||
"connect",
|
"connect",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_not_none(value: _T | None, message: str) -> _T:
|
||||||
|
"""Narrowing de type explicite : lève un AssertionError clair si `value`
|
||||||
|
est None, renvoie `value` (type non-Optional) sinon. Centralise ici
|
||||||
|
l'unique suppression Bandit/Ruff de ce type dans tout le moteur - un
|
||||||
|
`assert` direct à chaque site d'appel dupliquait le même commentaire
|
||||||
|
de suppression à chaque fois (voir CODE_QUALITY.md, Sonar python:S7632 :
|
||||||
|
Sonar ne reconnaît pas deux commentaires # empilés sur une ligne, même
|
||||||
|
si Ruff et Bandit les acceptent chacun très bien)."""
|
||||||
|
assert value is not None, message # nosec B101 # noqa: S101 - narrowing de type, seule assert du genre ici
|
||||||
|
return value
|
||||||
@@ -24,46 +24,6 @@ services:
|
|||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
|
|
||||||
# SonarQube (rapport qualité — voir sonar-project.properties, CODE_QUALITY.md).
|
|
||||||
# Base PostgreSQL dédiée (jamais la H2 embarquée par défaut, pas adaptée
|
|
||||||
# en usage durable) — mêmes images que la stack locale (WSL,
|
|
||||||
# ~/sonarqube-stack/docker-compose.yml) pour rester cohérent.
|
|
||||||
# SONAR_DB_PASSWORD : requis, à définir dans le .env du serveur (jamais
|
|
||||||
# en dur ici) — même convention que REGISTRY_IMAGE ci-dessus.
|
|
||||||
# Port lié à 127.0.0.1 uniquement (jamais 0.0.0.0) : SonarQube n'est
|
|
||||||
# JAMAIS exposé directement sur internet, seul Caddy (sur l'hôte, voir
|
|
||||||
# sonar.forgebase.fr) reverse-proxy vers ce port local avec TLS
|
|
||||||
# (Let's Encrypt) devant.
|
|
||||||
sonar-postgres:
|
|
||||||
image: postgres:16
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: sonar
|
|
||||||
POSTGRES_PASSWORD: ${SONAR_DB_PASSWORD:?SONAR_DB_PASSWORD is required}
|
|
||||||
POSTGRES_DB: sonarqube
|
|
||||||
volumes:
|
|
||||||
- sonar_postgres_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
sonarqube:
|
|
||||||
image: sonarqube:community
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- sonar-postgres
|
|
||||||
environment:
|
|
||||||
SONAR_JDBC_URL: jdbc:postgresql://sonar-postgres:5432/sonarqube
|
|
||||||
SONAR_JDBC_USERNAME: sonar
|
|
||||||
SONAR_JDBC_PASSWORD: ${SONAR_DB_PASSWORD:?SONAR_DB_PASSWORD is required}
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:9000:9000"
|
|
||||||
volumes:
|
|
||||||
- sonarqube_data:/opt/sonarqube/data
|
|
||||||
- sonarqube_extensions:/opt/sonarqube/extensions
|
|
||||||
- sonarqube_logs:/opt/sonarqube/logs
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
forge_projects:
|
forge_projects:
|
||||||
forge_data:
|
forge_data:
|
||||||
sonar_postgres_data:
|
|
||||||
sonarqube_data:
|
|
||||||
sonarqube_extensions:
|
|
||||||
sonarqube_logs:
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Plan — lot JS "modernisation" (357 issues SonarQube)
|
||||||
|
|
||||||
|
Trace de la stratégie validée avant exécution — même valeur que
|
||||||
|
`docs/SESSION_RECAP.md` pour la Phase 3 : ce document décrit le plan tel
|
||||||
|
que discuté et confirmé, pas un compte-rendu de ce qui a déjà été fait.
|
||||||
|
Pour l'état d'avancement réel lot par lot, voir les commits de ce
|
||||||
|
chantier (référencés ici au fur et à mesure).
|
||||||
|
|
||||||
|
Origine : rapport SonarQube local (`http://localhost:9000`), catégorie
|
||||||
|
"code smells" JavaScript, 357 occurrences réparties sur 22 règles.
|
||||||
|
|
||||||
|
## 1. Répartition exacte par règle Sonar
|
||||||
|
|
||||||
|
| Règle | Nom | Occurrences |
|
||||||
|
|---|---|---|
|
||||||
|
| `S7773` | Number static methods preferred over globals (`parseFloat`→`Number.parseFloat`, etc.) | 119 |
|
||||||
|
| `S6582` | Optional chaining should be preferred | 98 |
|
||||||
|
| `S7781` | `replaceAll()` instead of `replace()` with global regex | 29 |
|
||||||
|
| `S7761` | Data attributes via `.dataset` | 22 |
|
||||||
|
| `S7765` | `.includes()` instead of `.indexOf()`/`.lastIndexOf()` | 19 |
|
||||||
|
| `S3972` | Conditionals should start on new lines | 15 |
|
||||||
|
| `S3776` | Cognitive Complexity too high | 9 |
|
||||||
|
| `S4138` | `for-of` should be used with Iterables | 6 |
|
||||||
|
| `S4624` | Template literals should not be nested | 6 |
|
||||||
|
| `S2703` | Variables should be declared explicitly (var globale implicite) | 6 |
|
||||||
|
| `S8786` | Regex non-linear backtracking (ReDoS) | 5 |
|
||||||
|
| `S3358` | Ternary operators should not be nested | 4 |
|
||||||
|
| `S7744` | Unnecessary fallback objects in object spread | 3 |
|
||||||
|
| `S2486` | Exceptions should not be ignored | 3 |
|
||||||
|
| `S6557` | `startsWith()`/`endsWith()` instead of manual check | 3 |
|
||||||
|
| `S7721` | Function should be moved to highest possible scope | 2 |
|
||||||
|
| `S4144` | Functions should not have identical implementations | 2 |
|
||||||
|
| `S6653` | `Object.hasOwn()` instead of `hasOwnProperty` | 2 |
|
||||||
|
| `S6671` | Literal used for promise rejection (pas une `Error`) | 1 |
|
||||||
|
| `S7755` | `.at()` instead of `[…length - index]` | 1 |
|
||||||
|
| `S7769` | Modern Math APIs (`Math.hypot` etc.) | 1 |
|
||||||
|
| `S3735` | `void` should not be used | 1 |
|
||||||
|
| **Total** | | **357** |
|
||||||
|
|
||||||
|
## 2. Classification
|
||||||
|
|
||||||
|
### Mécanique, sans risque sémantique (équivalence prouvée)
|
||||||
|
|
||||||
|
| Règle | Pourquoi c'est sûr |
|
||||||
|
|---|---|
|
||||||
|
| `S7773` | `parseFloat`/`parseInt`/`isNaN`/`isFinite` sont des alias exacts de `Number.*` depuis ES2015 |
|
||||||
|
| `S7781` | `replace()`→`replaceAll()` seulement quand la regex a déjà le flag `g` (précondition de la règle) |
|
||||||
|
| `S7765` | `.indexOf(x) !== -1` ⇔ `.includes(x)` — équivalence stricte pour ce pattern |
|
||||||
|
| `S7761` | `getAttribute('data-x')`→`.dataset.x` — mécanique si nom d'attribut statique |
|
||||||
|
| `S7744` | Le spread de `null`/`undefined` ne lève jamais — le fallback `\|\| {}` est toujours redondant |
|
||||||
|
| `S6557`, `S6653`, `S7755`, `S7769` | Équivalences directes, très faible volume (7 sites), pas besoin d'outil |
|
||||||
|
| `S3972` | Pur formatage, zéro impact comportemental |
|
||||||
|
|
||||||
|
### ⚠️ Correction par rapport à l'hypothèse de départ : `S6582` n'est PAS mécanique
|
||||||
|
|
||||||
|
`a && a.b` et `a?.b` divergent quand `a` est une valeur **fausse mais
|
||||||
|
définie** (`0`, `""`, `false`, `NaN`) : `a && a.b` s'arrête et renvoie
|
||||||
|
`a`, `a?.b` continue et accède à `.b`. Une transformation aveugle peut
|
||||||
|
changer le comportement. Malgré son volume (98, 2ᵉ plus grosse règle),
|
||||||
|
`S6582` est traité en examen au cas par cas, pas en lot mécanique.
|
||||||
|
|
||||||
|
### Nécessite un vrai examen au cas par cas
|
||||||
|
|
||||||
|
| Règle | Nature | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `S6582` (98) | Sémantique | Voir ci-dessus — sous-lots avec vérification du type de la valeur testée |
|
||||||
|
| `S8786` (5) | **Sécurité-adjacent** | Sites JS, distincts de l'exception Python déjà validée en Phase 3 (`auth/email_validation.py`) |
|
||||||
|
| `S2703` (6) | **Sécurité-adjacent** | 1 seul des 6 sites déjà examiné et validé (`bindings.js:179`, `gameData`) — 5 restent à vérifier, ne pas supposer qu'ils sont identiques |
|
||||||
|
| `S2486` (3) | **Sécurité-adjacent** | Exception avalée, peut masquer un vrai bug |
|
||||||
|
| `S3776` (9) | Refactor réel | Pas de fix mécanique — restructuration fonction par fonction |
|
||||||
|
| `S4624` (6), `S3358` (4) | Lisibilité | Extraction manuelle |
|
||||||
|
| `S4138` (6) | Risque de perte de l'index | Fix proposé par un outil mais vérifié site par site avant application |
|
||||||
|
| `S4144` (2), `S7721` (2) | Décision de conception | Dédupliquer/déplacer une fonction change la portée |
|
||||||
|
| `S6671` (1) | Change le type de la valeur de rejet | Impact possible sur du code aval qui inspecterait `.message`/`instanceof Error` |
|
||||||
|
| `S3735` (1) | Dépend de l'intention | À lire avant de trancher |
|
||||||
|
|
||||||
|
## 3. Outillage — vérifié empiriquement
|
||||||
|
|
||||||
|
- **`eslint-plugin-sonarjs`** (officiel SonarSource, 4.2.1, dernière version) : ne couvre qu'**une seule** des 22 règles avec un fixer (`S3972`) — les règles `S77xx` sont trop récentes pour cette version.
|
||||||
|
- **`eslint-plugin-unicorn`** : dernière version (75.0.0) exige ESLint ≥10 (incompatible avec notre 8.57.1). Version retenue : **55.0.0** (exige ESLint ≥8.56.0, compatible), ajoutée en devDependency permanente. 9 règles activées dans `.eslintrc.json` avec un fixer confirmé (`fixable: 'code'` vérifié dans le code source du plugin) :
|
||||||
|
- `unicorn/prefer-number-properties` (S7773)
|
||||||
|
- `unicorn/prefer-string-replace-all` (S7781)
|
||||||
|
- `unicorn/prefer-dom-node-dataset` (S7761)
|
||||||
|
- `unicorn/prefer-includes` (S7765)
|
||||||
|
- `unicorn/prefer-string-starts-ends-with` (S6557)
|
||||||
|
- `unicorn/prefer-modern-math-apis` (S7769)
|
||||||
|
- `unicorn/prefer-at` (S7755)
|
||||||
|
- `unicorn/no-useless-fallback-in-spread` (S7744)
|
||||||
|
- `unicorn/no-for-loop` (S4138)
|
||||||
|
- Uniquement ces 9 règles activées, jamais la config `recommended` complète du plugin (qui en contient des dizaines d'autres, jamais évaluées pour ce projet) — voir `CODE_QUALITY.md` section 2.
|
||||||
|
- **Dry-run réel** (`--fix-dry-run`) sur tout `static/js/**/*.js` avec ces 9 règles : **222 problèmes détectés, 222 avec un fix proposé.** Les comptes par règle ne correspondent pas exactement à ceux de Sonar (moteurs différents, ex. `prefer-includes` détecte 41 cas contre 19 pour `S7765` côté Sonar) — normal, la vérification finale se fait via un rescan Sonar après application, pas via le compte ESLint.
|
||||||
|
- **`@typescript-eslint`** (candidat pour combler `S6582`) : nécessite de remplacer tout le parser JS par le parser TypeScript — écarté, trop lourd pour un projet 100% JS.
|
||||||
|
- **`Object.hasOwn` (`S6653`, 2 sites) et `startsWith`/`endsWith` (`S6557`, 3 sites)** : aucun outil ne les a détectés dans le dry-run — 5 sites au total, correction manuelle directe.
|
||||||
|
|
||||||
|
## 4. Ordre de priorité
|
||||||
|
|
||||||
|
1. Sécurité-adjacent : `S8786` (5), `S2703` (6, dont 5 non-vérifiés), `S2486` (3) — 14 sites.
|
||||||
|
2. Structure/lisibilité à risque de bug : `S3776` (9), `S4624` (6), `S3358` (4), `S4144` (2), `S7721` (2), `S6671` (1), `S3735` (1) — 25 sites.
|
||||||
|
3. `S6582` optional chaining (98) — lot dédié à part.
|
||||||
|
4. Modernisation mécanique via outil (`S7773`, `S7781`, `S7765`, `S7761`, `S7744`, `S3972`, `S4138` vérifié) — le plus gros volume, le moins risqué, en dernier.
|
||||||
|
5. Reliquat manuel très faible volume (`S6557`, `S6653`, `S7755`, `S7769`) — 7 sites.
|
||||||
|
|
||||||
|
## 5. Découpage en 9 lots
|
||||||
|
|
||||||
|
| Lot | Contenu | Méthode |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `S8786` (5) | Lecture individuelle, diff par site |
|
||||||
|
| 2 | `S2703` (5 restants, hors `gameData` déjà validé) | Lecture individuelle — même pattern ou différent ? |
|
||||||
|
| 3 | `S2486` (3) | Lecture individuelle |
|
||||||
|
| 4 | `S3776` (9) | Refactor fonction par fonction |
|
||||||
|
| 5 | `S4624` + `S3358` + `S4144` + `S7721` + `S6671` + `S3735` (16) | Groupés, petits volumes, revue individuelle |
|
||||||
|
| 6 | `S6582` (98) | Sous-lots de ~20-25, vérification du type à chaque site |
|
||||||
|
| 7 | `S7773`+`S7781`+`S7765`+`S7761`+`S7744`+`S3972` via `eslint-plugin-unicorn`@55.0.0 `--fix` | `--fix` en un coup, diff complet relu avant commit |
|
||||||
|
| 8 | `S4138` (fix proposé par `no-for-loop`, vérifié un par un) | Semi-automatique |
|
||||||
|
| 9 | `S6557`+`S6653`+`S7755`+`S7769` (7) | Manuel direct |
|
||||||
|
|
||||||
|
**À chaque lot** : diff complet montré avant application, suite de tests
|
||||||
|
JS (`node --test static/js/play/__tests__/*.test.js
|
||||||
|
static/js/play/offline/__tests__/*.test.js
|
||||||
|
static/js/scenes/__tests__/*.test.js`, 241 tests) après application,
|
||||||
|
signalement immédiat de tout changement de comportement réel (pas
|
||||||
|
seulement du style). Rescan SonarQube local après le lot 7 (le plus gros
|
||||||
|
volume mécanique) pour confirmer la réduction réelle plutôt que de se
|
||||||
|
fier au seul compte ESLint, puis un rescan final après le lot 9.
|
||||||
|
|
||||||
|
## Note de séquencement Git
|
||||||
|
|
||||||
|
Les 9 règles `unicorn/*` ont été activées dans `.eslintrc.json` (et
|
||||||
|
`eslint-plugin-unicorn` ajouté en devDependency) **avant** que les 222
|
||||||
|
occurrences correspondantes soient corrigées — ce qui rend `pre-commit`/
|
||||||
|
la CI rouges sur ESLint dès maintenant si ces changements de config
|
||||||
|
étaient commités seuls. Ces changements restent donc **non commités**
|
||||||
|
tant que le lot 7 (la correction mécanique) n'est pas fait, pour ne
|
||||||
|
jamais introduire un commit où le lint échouerait sur `dev`.
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Compte-rendu — mise en place qualité de code (2026-09-15)
|
||||||
|
|
||||||
|
Compte-rendu chronologique de la session ayant mis en place le dispositif
|
||||||
|
qualité de code du moteur (Phases 1 à 4 du plan). Documente ce qui a été
|
||||||
|
fait, trouvé et décidé — pour savoir comment utiliser les outils au
|
||||||
|
quotidien, voir `CODE_QUALITY.md` (à venir, Phase 5).
|
||||||
|
|
||||||
|
Commits de référence : `c57420c8` (Phase 3), `2ff127f6` et `20f9398b`
|
||||||
|
(Phase 4).
|
||||||
|
|
||||||
|
## 1. Contexte et objectif
|
||||||
|
|
||||||
|
Le moteur disposait déjà d'une suite de tests (591 tests Python, 241
|
||||||
|
tests JS) mais d'aucun outillage de qualité de code, d'architecture ou de
|
||||||
|
détection de code mort — rien n'empêchait un import circulaire, une
|
||||||
|
fonction non typée, une variable inutilisée ou un champ de formulaire
|
||||||
|
inaccessible d'atteindre la production. L'objectif de cette session :
|
||||||
|
mettre en place un dispositif **strictement strict** (aucune règle
|
||||||
|
désactivée "pour ne pas casser le build" — l'existant est corrigé pour
|
||||||
|
satisfaire la config, jamais l'inverse), puis nettoyer l'existant pour le
|
||||||
|
faire passer au vert, en 4 phases :
|
||||||
|
|
||||||
|
1. Configuration des outils.
|
||||||
|
2. Audit de l'existant (rapport initial).
|
||||||
|
3. Corrections par lots (typage, sécurité, architecture, a11y, ESLint/
|
||||||
|
Stylelint).
|
||||||
|
4. Intégration continue (CI) — pour que ces vérifications protègent
|
||||||
|
aussi un push direct ou une PR, pas seulement la machine de qui
|
||||||
|
committe.
|
||||||
|
|
||||||
|
## 2. Outils mis en place (Phase 1)
|
||||||
|
|
||||||
|
| Outil | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| **Ruff** | Lint + formatage Python (remplace flake8/isort/black en un seul outil) |
|
||||||
|
| **Mypy** (`--strict`) | Typage statique Python — détecte les incohérences de type avant l'exécution |
|
||||||
|
| **Vulture** | Détection de code mort Python (fonctions/imports jamais utilisés) |
|
||||||
|
| **Bandit** | Analyse de sécurité Python (injections, primitives cryptographiques faibles, etc.) |
|
||||||
|
| **import-linter** | Fait respecter les couches applicatives (ex. `db/` ne doit dépendre d'aucun autre paquet du moteur) |
|
||||||
|
| **ESLint** (`airbnb-base`) | Lint JavaScript |
|
||||||
|
| **Stylelint** (`stylelint-config-standard`) | Lint CSS |
|
||||||
|
| **djLint** | Lint des templates Jinja |
|
||||||
|
| **SonarQube Community Build** | Analyse qualité/sécurité agrégée, auto-hébergée (Docker + PostgreSQL) sur `sonar.forgebase.fr` |
|
||||||
|
| **pre-commit** | Orchestre tous les hooks ci-dessus localement, bloquant, avant chaque commit |
|
||||||
|
|
||||||
|
## 3. Bilan chiffré avant/après (Phase 2-3)
|
||||||
|
|
||||||
|
État final, vérifié directement à plusieurs reprises au cours de la
|
||||||
|
session (dernière vérification avant le commit `c57420c8`) :
|
||||||
|
|
||||||
|
| Outil | Résultat final |
|
||||||
|
|---|---|
|
||||||
|
| Ruff (lint + format) | 0 erreur |
|
||||||
|
| Mypy `--strict` | 0 erreur sur 388 fichiers source |
|
||||||
|
| Vulture | 0 signalement |
|
||||||
|
| Bandit | 0 issue (23 suppressions `# nosec` documentées individuellement) |
|
||||||
|
| import-linter | 1 contrat ("Couches applicatives Forge Engine"), respecté |
|
||||||
|
| ESLint | 0 erreur (12 avertissements pré-existants `no-alert`, jugés acceptables) |
|
||||||
|
| Stylelint | 0 erreur |
|
||||||
|
| djLint | 49 erreurs H021 (styles inline) — **backlog assumé**, voir §8 |
|
||||||
|
|
||||||
|
**Note de transparence** : cette session n'a pas produit de rapport
|
||||||
|
d'audit initial formalisé et conservé (`CODE_QUALITY.md`/Phase 5 n'existe
|
||||||
|
pas encore) — un tableau détaillé "avant/après par paquet" avec des
|
||||||
|
comptes précis par outil n'est donc pas reconstituable après coup avec
|
||||||
|
certitude. Ce qui a été retenu au fil de la session :
|
||||||
|
|
||||||
|
- Le typage Mypy `--strict` a été déployé **paquet par paquet**, dans
|
||||||
|
l'ordre : `db` → `screens` → `auth` → `core` → `ai` → `routes` → puis
|
||||||
|
`publish`/`scripts`/`tests`/`app.py`/`build_css.py` — chaque paquet
|
||||||
|
validé à 0 erreur avant de passer au suivant. `routes/` était le plus
|
||||||
|
gros lot (366 signalements Mypy à lui seul avant correction).
|
||||||
|
`screens/` était le paquet le plus étendu en nombre de fichiers touchés
|
||||||
|
(95 fichiers dans le commit final).
|
||||||
|
- Ruff signalait de l'ordre de 800+ erreurs cumulées (F401/F811,
|
||||||
|
formatage, imports) avant nettoyage, toutes résolues.
|
||||||
|
- Un régression Vulture a été détectée et corrigée en cours de route (un
|
||||||
|
changement de forme d'import — groupé vs individuel — modifiait la
|
||||||
|
détection d'usage), traitée via `vulture_whitelist.py`.
|
||||||
|
|
||||||
|
Le rapport SonarQube (voir §8), lui, a été entièrement conservé et trié
|
||||||
|
point par point : **72 bugs**, **57 vulnérabilités**, **466 code
|
||||||
|
smells**, **0 security hotspot**, Quality Gate **OK**.
|
||||||
|
|
||||||
|
## 4. Vrais bugs trouvés et corrigés
|
||||||
|
|
||||||
|
Bugs de comportement réel (pas du style), chacun vérifié dans le diff du
|
||||||
|
commit `c57420c8` :
|
||||||
|
|
||||||
|
- **`db/definitions/create_definition.py` / `delete_definition.py`** —
|
||||||
|
un `relation_definition_id`/`definition_id` invalide faisait planter
|
||||||
|
la fonction avec un `TypeError` cru (indexation d'un `None` renvoyé par
|
||||||
|
`get_definition`). Remplacé par un `ValueError` explicite et lisible.
|
||||||
|
- **`db/connection.py` → `core/flask_app.py`** — inversion de dépendance
|
||||||
|
architecturale : `db/` (couche la plus basse) importait
|
||||||
|
`core.flask_app` en interne (`_install_teardown_safety_net`), en
|
||||||
|
violation du contrat import-linter. Extrait en
|
||||||
|
`install_teardown_safety_net(app)`, une fonction pure prenant l'app en
|
||||||
|
paramètre ; le câblage réel (l'import de `core.flask_app`) déplacé dans
|
||||||
|
un nouveau fichier `core/db_teardown_guard.py` (couche de câblage, qui
|
||||||
|
a le droit de dépendre des deux côtés).
|
||||||
|
- **`ai/chat.py::_describe_scene_state`** — si l'écran associé à une
|
||||||
|
conversation IA avait été supprimé entre-temps, la fonction plantait
|
||||||
|
avec un `TypeError` (indexation d'un `screen` à `None`). Ajout d'une
|
||||||
|
exception dédiée `ScreenDeletedError`, interceptée par
|
||||||
|
`run_chat_turn` pour renvoyer un message clair ("Cet écran a été
|
||||||
|
supprimé...") plutôt qu'un crash.
|
||||||
|
- **`routes/scenes/scene_object_geometry.py`** — condition de course :
|
||||||
|
l'objet de scène était relu (`get_scene_object`) **après** avoir
|
||||||
|
appliqué la mise à jour de géométrie, uniquement pour lire
|
||||||
|
`obj["scene_id"]` — si l'objet avait été supprimé entre-temps (onglet
|
||||||
|
concurrent), `obj` valait `None` et l'accès plantait. La vérification
|
||||||
|
d'existence est maintenant faite **avant** toute mise à jour, avec un
|
||||||
|
retour 404 propre si l'objet n'existe plus.
|
||||||
|
- **Fuite `</script>` dans le JSON embarqué (XSS potentiel)** — 25 sites
|
||||||
|
Python (`routes/scenes/scene_edit_view.py` ×22,
|
||||||
|
`routes/play/game_play.py` ×1, `publish/build_scorm_package.py` ×2)
|
||||||
|
utilisaient `json.dumps(...)` brut pour embarquer des données dans un
|
||||||
|
bloc `<script>` — un champ texte utilisateur (nom d'écran/objet/
|
||||||
|
variable, texte de dialogue...) contenant littéralement `</script>`
|
||||||
|
aurait refermé la balise prématurément et injecté du HTML/JS non
|
||||||
|
échappé sur la page. Nouveau helper partagé `db.json_for_script()`
|
||||||
|
(`json.dumps(data).replace("</", "<\\/")`), appliqué aux 25 sites ;
|
||||||
|
vérifié par un test dédié (rendu strictement identique quand aucun
|
||||||
|
`</` n'est présent, neutralisation confirmée sinon).
|
||||||
|
- **Fuite de handle fichier Windows (export SCORM)** —
|
||||||
|
`routes/publish/export_scorm.py` servait le zip exporté via
|
||||||
|
`send_file()` en mode passthrough, puis tentait de le supprimer
|
||||||
|
(`after_this_request`) pendant que Werkzeug tenait encore le fichier
|
||||||
|
ouvert — l'erreur (`PermissionError`) était silencieusement avalée par
|
||||||
|
un `contextlib.suppress(OSError)`, laissant le fichier temporaire sur
|
||||||
|
le disque à chaque export. Corrigé en bufferisant le zip en mémoire
|
||||||
|
(`io.BytesIO`) et en supprimant le fichier temporaire avant de
|
||||||
|
construire la réponse. Vérifié par un script de détection de fuite
|
||||||
|
(avant : un fichier de 7 Mo restait sur le disque après chaque export ;
|
||||||
|
après : aucune fuite).
|
||||||
|
- **Bug d'isolation de tests (compte admin partagé)** — la suite de
|
||||||
|
tests partage un seul compte admin sur toute une session pytest ; 22
|
||||||
|
sites (répartis dans `test_ai_tools.py`, `test_user_assets.py`,
|
||||||
|
`test_scene_object_add_asset.py`) créaient un "asset" sur ce compte
|
||||||
|
sans le nettoyer, polluant les tests suivants. Corrigé ponctuellement
|
||||||
|
aux 22 sites, **puis** doublé d'une fixture `autouse=True` dans
|
||||||
|
`tests/conftest.py` (`_cleanup_admin_assets`) qui nettoie
|
||||||
|
automatiquement tout asset créé pendant un test — filet de sécurité
|
||||||
|
pour un futur test qui oublierait le même nettoyage manuel.
|
||||||
|
|
||||||
|
## 5. Code mort supprimé
|
||||||
|
|
||||||
|
Chaque suppression vérifiée par une recherche exhaustive de toute
|
||||||
|
référence restante dans le code (pas seulement le signalement Vulture) :
|
||||||
|
|
||||||
|
- `db/definitions/add_field_to_definition.py`, `delete_field.py`,
|
||||||
|
`rename_definition.py`, `update_field.py`
|
||||||
|
- `db/rows/relation_options.py`, `rows_referencing.py`
|
||||||
|
- `screens/clause_list_codec.py`
|
||||||
|
- `screens/rendering/trigger_for.py`
|
||||||
|
|
||||||
|
Pour chacun : recherche du nom de la fonction/fichier dans tout le
|
||||||
|
dépôt (`grep` récursif) après suppression — zéro référence restante dans
|
||||||
|
le code exécutable (au pire une mention dans un commentaire de
|
||||||
|
documentation, sans impact fonctionnel).
|
||||||
|
|
||||||
|
## 6. Décisions de configuration vs changements de code
|
||||||
|
|
||||||
|
Cas où la configuration a été adaptée aux conventions réelles et déjà
|
||||||
|
établies du projet plutôt que de réécrire massivement le code existant :
|
||||||
|
|
||||||
|
| Règle désactivée/ajustée | Fichier | Raison |
|
||||||
|
|---|---|---|
|
||||||
|
| `selector-class-pattern`/`selector-id-pattern: null` | `.stylelintrc.json` | Le projet utilise du camelCase pour ses classes/id CSS (`.canvasElement`, `#scormProgress`...) depuis le début — `stylelint-config-standard` impose du kebab-case par défaut ; renommer aurait touché des milliers de sélecteurs et leurs usages JS/HTML pour un gain de lisibilité nul |
|
||||||
|
| `no-descending-specificity: null` | `.stylelintrc.json` | Le CSS existant est organisé par composant/fonctionnalité, pas par ordre strict de spécificité — se conformer aurait demandé de réordonner une grande partie du fichier |
|
||||||
|
| `no-param-reassign: "off"` | `.eslintrc.json` | Convention déjà répandue dans le code (réassignation de paramètres pour la commodité, ex. petites moulinettes sur `event`/élément) — vérifiée site par site avant de désactiver la règle plutôt que de tout réécrire |
|
||||||
|
| `no-use-before-define` limité aux variables/classes (`functions: false`) | `.eslintrc.json` | Pattern de callback "classique" très répandu : des gestionnaires `onclick="foo()"` posés dans le HTML référencent des fonctions déclarées plus bas dans le fichier JS — le hoisting des déclarations de fonction rend ça sûr, contrairement aux variables |
|
||||||
|
| `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat` : `"off"` | `.eslintrc.json` | Chacune vérifiée individuellement comme sûre pour ce code avant désactivation (style déjà cohérent dans le projet, ex. underscore-prefixed pour le "privé" par convention) |
|
||||||
|
| `max-len` porté à 120 | `.eslintrc.json` | Convention déjà en usage dans le code Python (Ruff `line-length = 120`) — alignée côté JS plutôt que forcer un retour à 80 sur tout le dépôt |
|
||||||
|
| `unused-imports/no-unused-vars` avec liste blanche de ~95 noms | `.eslintrc.json` | Fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja — invisibles pour l'analyse statique d'ESLint (qui ne lit pas le HTML), mais réellement utilisées |
|
||||||
|
|
||||||
|
## 7. Intégration CI (Phase 4)
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml` — déclenché sur chaque push (`main` et
|
||||||
|
`dev`) :
|
||||||
|
|
||||||
|
- **`lint-python`** (bloquant) — rejoue `ruff check`/`ruff format
|
||||||
|
--check`/`mypy .`/`vulture`/`bandit`/`lint-imports` dans une image
|
||||||
|
Docker jetable (mêmes commandes que `.pre-commit-config.yaml`, jamais
|
||||||
|
dupliquées avec des paramètres différents). `djlint` volontairement
|
||||||
|
exclu (backlog H021, voir §8).
|
||||||
|
- **`lint-js`** (bloquant) — `npm run lint:js` / `npm run lint:css` dans
|
||||||
|
une image `node:20-slim`.
|
||||||
|
- **`sonarqube`** (`continue-on-error: true`, non-bloquant) — scan
|
||||||
|
contre l'instance self-hébergée `sonar.forgebase.fr`. N'est **pas**
|
||||||
|
dans le `needs` de `build-and-push` : un échec ici n'affecte jamais le
|
||||||
|
reste du pipeline.
|
||||||
|
- **`build-and-push`** dépend désormais de `test-python`, `test-js`,
|
||||||
|
`lint-python` **et** `lint-js`.
|
||||||
|
- **`deploy`** sécurisé : le token du registre passe par
|
||||||
|
`--password-stdin` (jamais en argument `-p`, visible via `ps` sur le
|
||||||
|
serveur) ; la clé SSH privée est supprimée en fin de job
|
||||||
|
(`if: always()`, `rm -f` — ne peut jamais échouer même si le fichier
|
||||||
|
n'a jamais été créé).
|
||||||
|
|
||||||
|
Premier run réel après le push de `2ff127f6` (run CI Gitea #96, commit
|
||||||
|
`2ff127f6`) : `test-python` ✅, `test-js` ✅, `lint-python` ✅, `lint-js`
|
||||||
|
✅, `sonarqube` ❌ (attendu — infra pas encore prête, voir §8),
|
||||||
|
conclusion globale du run : **succès** (confirmant que le mode
|
||||||
|
non-bloquant de `sonarqube` fonctionne bien tel que configuré).
|
||||||
|
|
||||||
|
## 8. Points en suspens / backlog
|
||||||
|
|
||||||
|
- **djLint H021 (styles inline, 49 occurrences sur 6 templates)** —
|
||||||
|
backlogué explicitement, jamais désactivé silencieusement (documenté
|
||||||
|
dans chaque commit qui l'a sauté via `SKIP=djlint`). **Priorité
|
||||||
|
immédiate après cette session**, voir §9.
|
||||||
|
- **SonarQube en CI** — échoue actuellement (`Failed to query server
|
||||||
|
version`) : l'endpoint `sonar.forgebase.fr` (Caddy + Let's Encrypt)
|
||||||
|
n'est pas encore pleinement opérationnel côté serveur perso. Aucune
|
||||||
|
date communiquée pour la mise en service — à vérifier avec l'infra
|
||||||
|
avant de s'attendre à un scan CI qui aboutit.
|
||||||
|
- **Rapport SonarQube — 466 code smells** — analysés en volume mais non
|
||||||
|
triés individuellement (contrairement aux 72 bugs et 57 vulnérabilités,
|
||||||
|
entièrement passés en revue point par point cette session). Lot séparé
|
||||||
|
à traiter après la Phase 4.
|
||||||
|
- **Trajectoire vers un SonarQube bloquant en CI** — une fois (a) l'infra
|
||||||
|
serveur opérationnelle et (b) les code smells triés (faux positifs
|
||||||
|
documentés en `NOSONAR`, comme déjà fait pour les 72 bugs/57
|
||||||
|
vulnérabilités), retirer `continue-on-error: true` du job `sonarqube`
|
||||||
|
et l'ajouter au `needs` de `build-and-push`.
|
||||||
|
- **Phase 5 (CODE_QUALITY.md)** — non commencée : documentation de
|
||||||
|
référence sur les outils et comment les utiliser au quotidien (ce
|
||||||
|
fichier-ci n'en tient pas lieu, voir l'introduction).
|
||||||
|
|
||||||
|
## 9. Prochaines étapes
|
||||||
|
|
||||||
|
**Priorité immédiate** (juste après cette session, avant ou après la
|
||||||
|
Phase 4 selon décision à prendre le moment venu) : supprimer le style
|
||||||
|
inline (djLint H021) au profit d'un système de modèles/classes CSS
|
||||||
|
réutilisables, plutôt que de continuer à accumuler des exceptions sur
|
||||||
|
cette règle.
|
||||||
|
|
||||||
|
Ensuite, dans l'ordre discuté : trier les 466 code smells SonarQube,
|
||||||
|
activer SonarQube en CI dès que l'infra serveur est prête, puis rédiger
|
||||||
|
`CODE_QUALITY.md` (Phase 5).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Script de verification empirique pour les 5 sites NOSONAR javascript:S8786
|
||||||
|
// (voir CODE_QUALITY.md, section 5) - teste les 3 regex distinctes flaguees
|
||||||
|
// (2 sont dupliquees dans 2 fichiers chacune) contre des entrees construites
|
||||||
|
// pour maximiser l'ambiguite que l'analyseur Sonar soupconne, a plusieurs
|
||||||
|
// tailles croissantes. Un vrai ReDoS montrerait une croissance exponentielle
|
||||||
|
// du temps avec la taille (x10 la taille -> beaucoup plus que x10 le temps).
|
||||||
|
//
|
||||||
|
// A relancer si l'une de ces 3 regex change de forme, pour confirmer que le
|
||||||
|
// NOSONAR reste justifie plutot que de le recopier aveuglement.
|
||||||
|
//
|
||||||
|
// Usage : node docs/redos_probe_s8786.js
|
||||||
|
|
||||||
|
function timeIt(label, fn) {
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
const result = fn();
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const ms = Number(end - start) / 1e6;
|
||||||
|
console.log(`${label}: ${ms.toFixed(2)}ms -> match=${result !== null}`);
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== Pattern A (filter-repeater-rows.js:10, panel-init.js:254) ===');
|
||||||
|
const patternA = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||||
|
for (const n of [1000, 10000, 50000, 100000]) {
|
||||||
|
const evil = `{{${'a'.repeat(n)}`; // pas de point, pas de fermeture
|
||||||
|
timeIt(` n=${n} (pas de point, pas de fermeture)`, () => patternA.exec(evil));
|
||||||
|
}
|
||||||
|
for (const n of [1000, 10000, 50000, 100000]) {
|
||||||
|
const evil = `{{${'a'.repeat(n)}.${'b'.repeat(n)}`; // point present, pas de fermeture
|
||||||
|
timeIt(` n=${n} (point present, pas de fermeture)`, () => patternA.exec(evil));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('=== Pattern B (filter-repeater-rows.js:11, panel-init.js:261) ===');
|
||||||
|
const patternB = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||||
|
for (const n of [1000, 5000, 10000, 20000]) {
|
||||||
|
const evil = `{{$a${'.b'.repeat(n)}`; // repetition simple, pas de fermeture
|
||||||
|
timeIt(` n=${n} (${n} segments ".b", pas de fermeture)`, () => patternB.exec(evil));
|
||||||
|
}
|
||||||
|
for (const n of [1000, 5000, 10000, 20000]) {
|
||||||
|
const evil = `{{$a${'.b[0]'.repeat(n)}`; // alternance des deux branches
|
||||||
|
timeIt(` n=${n} (${n} segments ".b[0]" alternes, pas de fermeture)`, () => patternB.exec(evil));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('=== Pattern C (xapi-client.js:132) ===');
|
||||||
|
const patternC = /\/+$/;
|
||||||
|
for (const n of [10000, 100000, 1000000]) {
|
||||||
|
const evil = '/'.repeat(n);
|
||||||
|
timeIt(` n=${n} (que des slashes)`, () => patternC.exec(evil));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('=== Verification d\'equivalence (entrees valides normales, comportement inchange attendu) ===');
|
||||||
|
console.log('A sur "{{Objet.champ}}":', patternA.exec('{{Objet.champ}}'));
|
||||||
|
console.log('B sur "{{$var.champ[0].sous}}":', patternB.exec('{{$var.champ[0].sous}}'));
|
||||||
|
console.log('C sur "https://host///":', 'https://host///'.replace(patternC, ''));
|
||||||
@@ -2,6 +2,7 @@ from flask import abort, g, jsonify, redirect, request, url_for
|
|||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from core.sprite_gate import forbid_admin_only_background, forbid_admin_only_character
|
from core.sprite_gate import forbid_admin_only_background, forbid_admin_only_character
|
||||||
@@ -94,11 +95,11 @@ def scene_object_add(slug: str, screen_id: int) -> Response:
|
|||||||
# _clamp_to_camera pour l'IA, ici pour un dépôt manuel.
|
# _clamp_to_camera pour l'IA, ici pour un dépôt manuel.
|
||||||
obj_for_clamp = screens.get_scene_object(slug, new_id)
|
obj_for_clamp = screens.get_scene_object(slug, new_id)
|
||||||
screen = screens.get_screen(slug, screen_id)
|
screen = screens.get_screen(slug, screen_id)
|
||||||
assert obj_for_clamp is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj_for_clamp = db.assert_not_none(
|
||||||
"new_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
obj_for_clamp, "new_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
||||||
)
|
)
|
||||||
assert screen is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
screen = db.assert_not_none(
|
||||||
"screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)"
|
screen, "screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)"
|
||||||
)
|
)
|
||||||
max_x = max(0, screen["scene_width"] - obj_for_clamp["width"])
|
max_x = max(0, screen["scene_width"] - obj_for_clamp["width"])
|
||||||
max_y = max(0, screen["scene_height"] - obj_for_clamp["height"])
|
max_y = max(0, screen["scene_height"] - obj_for_clamp["height"])
|
||||||
@@ -110,9 +111,7 @@ def scene_object_add(slug: str, screen_id: int) -> Response:
|
|||||||
)
|
)
|
||||||
if request.headers.get("X-Requested-With") == "fetch":
|
if request.headers.get("X-Requested-With") == "fetch":
|
||||||
obj = screens.get_scene_object(slug, new_id)
|
obj = screens.get_scene_object(slug, new_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "new_id vient d'etre cree par screens.add_scene_object plus haut")
|
||||||
"new_id vient d'etre cree par screens.add_scene_object plus haut"
|
|
||||||
)
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": new_id,
|
"id": new_id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -33,7 +34,5 @@ def scene_object_collision(slug: str, object_id: int) -> Response | tuple[Respon
|
|||||||
|
|
||||||
screens.set_scene_object_collision(slug, object_id, settings)
|
screens.set_scene_object_collision(slug, object_id, settings)
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)")
|
||||||
"object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)"
|
|
||||||
)
|
|
||||||
return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)})
|
return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -34,8 +35,8 @@ def scene_object_geometry(slug: str, object_id: int) -> Response | tuple[Respons
|
|||||||
# voir iaChatRefreshAfterTurn() dans static/js/ia/ia-chat.js), rendant
|
# voir iaChatRefreshAfterTurn() dans static/js/ia/ia-chat.js), rendant
|
||||||
# injoignable à la souris tout ce qui dépassait la scène nominale.
|
# injoignable à la souris tout ce qui dépassait la scène nominale.
|
||||||
screen = screens.get_screen(slug, obj["scene_id"])
|
screen = screens.get_screen(slug, obj["scene_id"])
|
||||||
assert screen is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
screen = db.assert_not_none(
|
||||||
"obj existe (verifie plus haut) et _scene_objects.scene_id a ON DELETE CASCADE vers _screens"
|
screen, "obj existe (verifie plus haut) et _scene_objects.scene_id a ON DELETE CASCADE vers _screens"
|
||||||
)
|
)
|
||||||
objects = screens.list_scene_objects(slug, obj["scene_id"])
|
objects = screens.list_scene_objects(slug, obj["scene_id"])
|
||||||
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from core.sprite_gate import forbid_admin_only_character
|
from core.sprite_gate import forbid_admin_only_character
|
||||||
@@ -22,7 +23,5 @@ def scene_object_personnage_data(slug: str, object_id: int) -> Response | tuple[
|
|||||||
forbid_admin_only_character(body["forge_character"])
|
forbid_admin_only_character(body["forge_character"])
|
||||||
screens.set_scene_object_personnage_data(slug, object_id, body)
|
screens.set_scene_object_personnage_data(slug, object_id, body)
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)")
|
||||||
"object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)"
|
|
||||||
)
|
|
||||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
|
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -20,7 +21,5 @@ def scene_object_quiz_config(slug: str, object_id: int) -> Response | tuple[Resp
|
|||||||
# coup, plutôt que de deviner quelle balise ajouter/retirer côté
|
# coup, plutôt que de deviner quelle balise ajouter/retirer côté
|
||||||
# client.
|
# client.
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id valide : set_scene_object_quiz_config a renvoye une config non-None")
|
||||||
"object_id valide : set_scene_object_quiz_config a renvoye une config non-None"
|
|
||||||
)
|
|
||||||
return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)})
|
return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)})
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ def full_game_payload(slug: str, player_id: str = db.PLAYER_SHARED) -> dict[str,
|
|||||||
fields_meta = {}
|
fields_meta = {}
|
||||||
for d in definitions:
|
for d in definitions:
|
||||||
full = db.get_definition(slug, d["id"])
|
full = db.get_definition(slug, d["id"])
|
||||||
assert full is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
full = db.assert_not_none(
|
||||||
"d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici"
|
full, "d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici"
|
||||||
)
|
)
|
||||||
rows = db.list_rows(slug, full, player_id)
|
rows = db.list_rows(slug, full, player_id)
|
||||||
# Un champ "relation" vit dans une colonne SQL "<champ>_id", jamais
|
# Un champ "relation" vit dans une colonne SQL "<champ>_id", jamais
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
from werkzeug.test import TestResponse
|
from werkzeug.test import TestResponse
|
||||||
|
|
||||||
|
import db
|
||||||
import routes # noqa: F401 - enregistre toutes les routes
|
import routes # noqa: F401 - enregistre toutes les routes
|
||||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
@@ -59,8 +60,9 @@ from core.flask_app import app
|
|||||||
|
|
||||||
def _redirect_id(resp: TestResponse, pattern: str) -> int:
|
def _redirect_id(resp: TestResponse, pattern: str) -> int:
|
||||||
match = re.search(pattern, resp.headers["Location"]) # NOSONAR S8371 - Location attendue, KeyError = echec clair
|
match = re.search(pattern, resp.headers["Location"]) # NOSONAR S8371 - Location attendue, KeyError = echec clair
|
||||||
assert match is not None, ( # nosec B101 # noqa: S101 - script d'usage unique, jamais execute en production
|
match = db.assert_not_none(
|
||||||
f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}" # NOSONAR S8371 - idem
|
match,
|
||||||
|
f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}", # NOSONAR S8371 - idem
|
||||||
)
|
)
|
||||||
return int(match.group(1))
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ function iaChatRefreshAfterTurn() {
|
|||||||
// dessus plutôt que de rejouer un script inline.
|
// dessus plutôt que de rejouer un script inline.
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const newWidth = parseFloat(canvas.style.width);
|
const newWidth = Number.parseFloat(canvas.style.width);
|
||||||
const newHeight = parseFloat(canvas.style.height);
|
const newHeight = Number.parseFloat(canvas.style.height);
|
||||||
if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
||||||
if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ function iaChatLoadMessages(conversationId) {
|
|||||||
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`)
|
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((messages) => {
|
.then((messages) => {
|
||||||
if (!messages || !messages.length) {
|
if (!messages?.length) {
|
||||||
log.innerHTML = iaChatEmptyStateHtml();
|
log.innerHTML = iaChatEmptyStateHtml();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,32 @@ global.window = global.window || {};
|
|||||||
global.gameData = global.gameData || {};
|
global.gameData = global.gameData || {};
|
||||||
const {
|
const {
|
||||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||||
applyObjectProperty, clampSceneObjectPosition,
|
applyElementProperty, applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||||
} = require('../actions.js');
|
} = require('../actions.js');
|
||||||
|
|
||||||
function fakeImg() {
|
function fakeImg() {
|
||||||
return { src: '' };
|
return { src: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mock DOM minimal pour applyElementProperty() — classList/style/dataset
|
||||||
|
// réels (Set + objet), pas juste des espions, pour pouvoir vérifier l'état
|
||||||
|
// après coup (ex. "la classe a bien été retirée", pas seulement "la
|
||||||
|
// méthode a été appelée").
|
||||||
|
function fakeElement() {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
textContent: '',
|
||||||
|
style: { display: '', visibility: '' },
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add: (c) => classes.add(c),
|
||||||
|
remove: (c) => classes.delete(c),
|
||||||
|
toggle: (c) => { if (classes.has(c)) classes.delete(c); else classes.add(c); },
|
||||||
|
has: (c) => classes.has(c),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => {
|
test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => {
|
||||||
const el = fakeImg();
|
const el = fakeImg();
|
||||||
const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true };
|
const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true };
|
||||||
@@ -89,6 +108,113 @@ test('resolveSpriteFrames — résout depuis gameData.personnage_animations par
|
|||||||
// maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne
|
// maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne
|
||||||
// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent
|
// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent
|
||||||
// désormais à [0, scene_width/height - sa propre taille].
|
// désormais à [0, scene_width/height - sa propre taille].
|
||||||
|
// Couverture de TOUTES les branches d'applyElementProperty() (39 -> lot 4
|
||||||
|
// "modernisation JS", S3776) AVANT tout refactor, pour garantir que
|
||||||
|
// l'extraction en sous-fonctions ne change aucun comportement — voir
|
||||||
|
// CODE_QUALITY.md.
|
||||||
|
test('applyElementProperty — "contenu" : texte littéral, null/undefined -> vidé', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'contenu', 'Bonjour');
|
||||||
|
assert.equal(el.textContent, 'Bonjour');
|
||||||
|
applyElementProperty(el, 'contenu', null);
|
||||||
|
assert.equal(el.textContent, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — "surbrillance" : toggle/off/valeur par défaut (activer)', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'surbrillance', 'toggle');
|
||||||
|
assert.ok(el.classList.has('forgeHighlight'));
|
||||||
|
applyElementProperty(el, 'surbrillance', 'toggle');
|
||||||
|
assert.ok(!el.classList.has('forgeHighlight'));
|
||||||
|
applyElementProperty(el, 'surbrillance', 'on');
|
||||||
|
assert.ok(el.classList.has('forgeHighlight'));
|
||||||
|
applyElementProperty(el, 'surbrillance', 'off');
|
||||||
|
assert.ok(!el.classList.has('forgeHighlight'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — "desactive" : toggle/off/valeur par défaut (désactiver)', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'desactive', 'toggle');
|
||||||
|
assert.ok(el.classList.has('forgeDisabled'));
|
||||||
|
applyElementProperty(el, 'desactive', 'toggle');
|
||||||
|
assert.ok(!el.classList.has('forgeDisabled'));
|
||||||
|
applyElementProperty(el, 'desactive', 'on');
|
||||||
|
assert.ok(el.classList.has('forgeDisabled'));
|
||||||
|
applyElementProperty(el, 'desactive', 'off');
|
||||||
|
assert.ok(!el.classList.has('forgeDisabled'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — "visibilite" : toggle (selon état actuel), masque, invisible, valeur par défaut (visible)', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'visibilite', 'toggle');
|
||||||
|
assert.equal(el.style.display, 'none');
|
||||||
|
applyElementProperty(el, 'visibilite', 'toggle');
|
||||||
|
assert.equal(el.style.display, '');
|
||||||
|
applyElementProperty(el, 'visibilite', 'masque');
|
||||||
|
assert.equal(el.style.display, 'none');
|
||||||
|
applyElementProperty(el, 'visibilite', 'invisible');
|
||||||
|
assert.equal(el.style.display, '');
|
||||||
|
assert.equal(el.style.visibility, 'hidden');
|
||||||
|
applyElementProperty(el, 'visibilite', 'visible');
|
||||||
|
assert.equal(el.style.display, '');
|
||||||
|
assert.equal(el.style.visibility, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — "orientation" : toggle/gauche/valeur par défaut (droite)', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'orientation', 'toggle');
|
||||||
|
assert.ok(el.classList.has('forgeFlipped'));
|
||||||
|
applyElementProperty(el, 'orientation', 'toggle');
|
||||||
|
assert.ok(!el.classList.has('forgeFlipped'));
|
||||||
|
applyElementProperty(el, 'orientation', 'gauche');
|
||||||
|
assert.ok(el.classList.has('forgeFlipped'));
|
||||||
|
applyElementProperty(el, 'orientation', 'droite');
|
||||||
|
assert.ok(!el.classList.has('forgeFlipped'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — "pos_x_relatif"/"pos_y_relatif" : ajoute au pixel/pourcentage actuel, ignore une valeur non numérique', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
el.style.left = '10%';
|
||||||
|
applyElementProperty(el, 'pos_x_relatif', '5');
|
||||||
|
assert.equal(el.style.left, '15%');
|
||||||
|
el.style.top = '20%';
|
||||||
|
applyElementProperty(el, 'pos_y_relatif', '-5');
|
||||||
|
assert.equal(el.style.top, '15%');
|
||||||
|
applyElementProperty(el, 'pos_x_relatif', 'pas-un-nombre');
|
||||||
|
assert.equal(el.style.left, '15%', 'valeur non numérique : aucun changement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — propriété inconnue ou valeur null/undefined : aucun effet', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'propriete_qui_n_existe_pas', 'x');
|
||||||
|
applyElementProperty(el, 'width', undefined);
|
||||||
|
applyElementProperty(el, 'width', null);
|
||||||
|
assert.equal(el.style.width, undefined, 'jamais touché');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — valeur "a||b" (va-et-vient) : bascule à chaque appel, persiste via dataset', () => {
|
||||||
|
// Premier appel (dataset vide, "pas encore à la 2e valeur") -> bascule
|
||||||
|
// sur la SECONDE valeur (parts[1]) et mémorise l'état "à la 2e" (voir
|
||||||
|
// le code : atSecond=false -> next=parts[1], dataset mis à '1').
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'width', '10||90');
|
||||||
|
assert.equal(el.style.width, '90%');
|
||||||
|
assert.equal(el.dataset.togglewidth, '1');
|
||||||
|
applyElementProperty(el, 'width', '10||90');
|
||||||
|
assert.equal(el.style.width, '10%');
|
||||||
|
assert.equal(el.dataset.togglewidth, '0');
|
||||||
|
applyElementProperty(el, 'width', '10||90');
|
||||||
|
assert.equal(el.style.width, '90%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyElementProperty — valeur directe (pas de "||") : assignation simple avec suffixe', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
applyElementProperty(el, 'width', '42');
|
||||||
|
assert.equal(el.style.width, '42%');
|
||||||
|
applyElementProperty(el, 'bg_color', '#ff0000');
|
||||||
|
assert.equal(el.style.backgroundColor, '#ff0000');
|
||||||
|
});
|
||||||
|
|
||||||
function withScreensData(screenData, fn) {
|
function withScreensData(screenData, fn) {
|
||||||
const previousScreens = global.screensData;
|
const previousScreens = global.screensData;
|
||||||
const previousId = global.window.currentScreenId;
|
const previousId = global.window.currentScreenId;
|
||||||
@@ -97,6 +223,276 @@ function withScreensData(screenData, fn) {
|
|||||||
try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; }
|
try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Couverture de TOUTES les branches de runActionNode() (58 -> lot 4
|
||||||
|
// "modernisation JS", S3776) AVANT tout refactor — un seul appel par
|
||||||
|
// action_type, avec des dépendances globales (showScreen, fetch,
|
||||||
|
// FORGE_PLAY_URLS, les fonctions forgeApply*Offline...) remplacées par
|
||||||
|
// des espions qui enregistrent l'appel plutôt que par leur vraie
|
||||||
|
// implémentation (déjà testée ailleurs : screens.js, apply-actions.js).
|
||||||
|
// Objectif : garantir que l'extraction en sous-fonctions n'envoie pas le
|
||||||
|
// mauvais argument ou n'appelle pas la mauvaise fonction — pas retester
|
||||||
|
// le comportement de ces dépendances elles-mêmes.
|
||||||
|
const ACTION_GLOBAL_KEYS = [
|
||||||
|
'showScreen', 'screenIndexById', 'applyOpenRowBindings', 'bindClicks', 'bindHoverTexts', 'bindHoverTriggers',
|
||||||
|
'refreshRuntimeData', 'forgeApplyDataActionOffline', 'forgeApplyVariableActionOffline',
|
||||||
|
'forgeApplyScoreActionOffline', 'forgeApplyStatusActionOffline', 'forgeApplyAddRowActionOffline',
|
||||||
|
'FORGE_PLAY_URLS', 'fetch', 'Audio',
|
||||||
|
];
|
||||||
|
|
||||||
|
// `fn` peut être async (branches qui font un vrai fetch) — restaurer les
|
||||||
|
// globales seulement APRÈS la résolution de son résultat (via .finally()
|
||||||
|
// sur la promesse renvoyée, pas un await bloquant ici) est obligatoire :
|
||||||
|
// un simple `try/finally` synchrone les restaurait pendant que la chaîne
|
||||||
|
// .then() de runActionNode tournait encore (bug rencontré en écrivant ce
|
||||||
|
// test : "refreshRuntimeData is not a function" alors que le stub était
|
||||||
|
// bien posé — juste déjà retiré par le moment où le .then() s'exécutait).
|
||||||
|
// Reste appelable SANS await pour les cas synchrones (pas de changement
|
||||||
|
// de signature pour la majorité des tests déjà écrits).
|
||||||
|
function withActionNodeGlobals(fn) {
|
||||||
|
const calls = [];
|
||||||
|
const previous = {};
|
||||||
|
ACTION_GLOBAL_KEYS.forEach((key) => { previous[key] = global[key]; });
|
||||||
|
const previousDispatch = global.window.dispatchGameEvent;
|
||||||
|
const previousOffline = global.window.FORGE_OFFLINE;
|
||||||
|
|
||||||
|
global.showScreen = (id) => calls.push(['showScreen', id]);
|
||||||
|
global.screenIndexById = (id) => global.screensData.findIndex((s) => s.id === id);
|
||||||
|
global.applyOpenRowBindings = () => calls.push(['applyOpenRowBindings']);
|
||||||
|
global.bindClicks = () => calls.push(['bindClicks']);
|
||||||
|
global.bindHoverTexts = () => calls.push(['bindHoverTexts']);
|
||||||
|
global.bindHoverTriggers = () => calls.push(['bindHoverTriggers']);
|
||||||
|
global.window.dispatchGameEvent = (id) => calls.push(['dispatchGameEvent', id]);
|
||||||
|
global.refreshRuntimeData = () => { calls.push(['refreshRuntimeData']); return Promise.resolve('refreshed'); };
|
||||||
|
global.forgeApplyDataActionOffline = (gd, node) => calls.push(['forgeApplyDataActionOffline', node.id]);
|
||||||
|
global.forgeApplyVariableActionOffline = (gd, node) => calls.push(['forgeApplyVariableActionOffline', node.id]);
|
||||||
|
global.forgeApplyScoreActionOffline = (gd, node) => calls.push(['forgeApplyScoreActionOffline', node.id]);
|
||||||
|
global.forgeApplyStatusActionOffline = (gd, node) => calls.push(['forgeApplyStatusActionOffline', node.id]);
|
||||||
|
global.forgeApplyAddRowActionOffline = (gd, node) => { calls.push(['forgeApplyAddRowActionOffline', node.id]); return 4242; };
|
||||||
|
global.FORGE_PLAY_URLS = {
|
||||||
|
flowNodeRunData: '/run-data/999999',
|
||||||
|
flowNodeRunVariable: '/run-variable/999999',
|
||||||
|
flowNodeRunScore: '/run-score/999999',
|
||||||
|
flowNodeRunStatus: '/run-status/999999',
|
||||||
|
flowNodeRunAddRow: '/run-add-row/999999',
|
||||||
|
};
|
||||||
|
global.fetch = (url, opts) => {
|
||||||
|
calls.push(['fetch', url, (opts && opts.method) || 'GET']);
|
||||||
|
return Promise.resolve({ json: () => Promise.resolve({ row_id: 4343 }) });
|
||||||
|
};
|
||||||
|
global.Audio = function FakeAudio(src) {
|
||||||
|
calls.push(['Audio', src]);
|
||||||
|
return { play: () => ({ catch: () => {} }) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
ACTION_GLOBAL_KEYS.forEach((key) => { global[key] = previous[key]; });
|
||||||
|
global.window.dispatchGameEvent = previousDispatch;
|
||||||
|
global.window.FORGE_OFFLINE = previousOffline;
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = fn(calls);
|
||||||
|
} catch (e) {
|
||||||
|
restore();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
return result.finally(restore);
|
||||||
|
}
|
||||||
|
restore();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runActionNode — ecran_suivant : avance si un écran suivant existe, ne fait rien en fin de liste', () => {
|
||||||
|
withScreensData({ id: 1 }, () => {
|
||||||
|
global.screensData = [{ id: 1 }, { id: 2 }];
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'ecran_suivant' });
|
||||||
|
assert.deepEqual(calls, [['showScreen', 2]]);
|
||||||
|
});
|
||||||
|
global.window.currentScreenId = 2;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'ecran_suivant' });
|
||||||
|
assert.deepEqual(calls, [], 'déjà le dernier écran : aucun changement');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — ecran_precedent : recule si un écran précédent existe, ne fait rien en tête de liste', () => {
|
||||||
|
global.screensData = [{ id: 1 }, { id: 2 }];
|
||||||
|
global.window.currentScreenId = 2;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'ecran_precedent' });
|
||||||
|
assert.deepEqual(calls, [['showScreen', 1]]);
|
||||||
|
});
|
||||||
|
global.window.currentScreenId = 1;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'ecran_precedent' });
|
||||||
|
assert.deepEqual(calls, [], 'déjà le premier écran : aucun changement');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — aller_a : montre l\'écran cible si présent, ignore sans target_screen_id', () => {
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'aller_a', target_screen_id: 7 });
|
||||||
|
assert.deepEqual(calls, [['showScreen', 7]]);
|
||||||
|
});
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'aller_a' });
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — ouvrir_ligne : mémorise la ligne cliquée, affiche l\'écran, rebranche les écouteurs', () => {
|
||||||
|
global.window.lastClickedRowId = 5;
|
||||||
|
global.window.lastClickedDefinitionId = 9;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'ouvrir_ligne', target_screen_id: 3 });
|
||||||
|
assert.deepEqual(global.window.currentOpenRow, { definitionId: 9, rowId: 5 });
|
||||||
|
assert.deepEqual(calls, [['showScreen', 3], ['applyOpenRowBindings'], ['bindClicks'], ['bindHoverTexts'], ['bindHoverTriggers']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — modifier_element : applique la propriété sur l\'élément trouvé, ignore si absent', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
global.document = { querySelector: (sel) => (sel.includes('"5"') ? el : null) };
|
||||||
|
runActionNode({
|
||||||
|
action_type: 'modifier_element', target_element_id: 5, element_property: 'contenu', element_value: 'Salut',
|
||||||
|
});
|
||||||
|
assert.equal(el.textContent, 'Salut');
|
||||||
|
// Aucune erreur levée quand l'élément n'existe pas (id différent -> querySelector renvoie null).
|
||||||
|
runActionNode({
|
||||||
|
action_type: 'modifier_element', target_element_id: 999, element_property: 'contenu', element_value: 'x',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — modifier_objet_scene : applique la propriété sur l\'objet trouvé (par target_element_id ou target_object_id)', () => {
|
||||||
|
const el = fakeElement();
|
||||||
|
el.style.width = '10%';
|
||||||
|
global.document = { querySelector: () => el };
|
||||||
|
runActionNode({
|
||||||
|
action_type: 'modifier_objet_scene', target_object_id: 8, element_property: 'contenu', element_value: 'Objet',
|
||||||
|
});
|
||||||
|
assert.equal(el.textContent, 'Objet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — declencher_evenement : notifie l\'événement personnalisé ciblé', () => {
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'declencher_evenement', target_custom_event_id: 12 });
|
||||||
|
assert.deepEqual(calls, [['dispatchGameEvent', 12]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — activer_onglet : montre l\'élément visé, masque ses frères directs, ignore les autres', () => {
|
||||||
|
const elTarget = fakeElement();
|
||||||
|
const elSibling = fakeElement();
|
||||||
|
elSibling.style.display = '';
|
||||||
|
const els = { 1: elTarget, 2: elSibling };
|
||||||
|
global.document = { querySelector: (sel) => els[sel.match(/"(\d+)"/)[1]] };
|
||||||
|
withScreensData({
|
||||||
|
id: 1,
|
||||||
|
elements: [{ id: 1, parent_id: null }, { id: 2, parent_id: null }, { id: 3, parent_id: 99 }],
|
||||||
|
}, () => {
|
||||||
|
runActionNode({ action_type: 'activer_onglet', target_element_id: 1 });
|
||||||
|
});
|
||||||
|
assert.equal(elTarget.style.display, '');
|
||||||
|
assert.equal(elSibling.style.display, 'none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — attendre : résout après le délai (data_value en secondes), 0 si non numérique', async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
await runActionNode({ action_type: 'attendre', data_value: '0.01' });
|
||||||
|
assert.ok(Date.now() - t0 >= 5, 'doit avoir réellement attendu');
|
||||||
|
await runActionNode({ action_type: 'attendre', data_value: 'pas-un-nombre' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — modifier_donnee : hors ligne délègue à forgeApplyDataActionOffline, en ligne POST vers flowNodeRunData', async () => {
|
||||||
|
global.window.FORGE_OFFLINE = true;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'modifier_donnee', id: 11 });
|
||||||
|
assert.deepEqual(calls, [['forgeApplyDataActionOffline', 11], ['refreshRuntimeData']]);
|
||||||
|
});
|
||||||
|
global.window.FORGE_OFFLINE = false;
|
||||||
|
await withActionNodeGlobals(async (calls) => {
|
||||||
|
await runActionNode({ action_type: 'modifier_donnee', id: 999999 });
|
||||||
|
assert.equal(calls[0][0], 'fetch');
|
||||||
|
assert.equal(calls[0][1], '/run-data/999999');
|
||||||
|
assert.equal(calls[0][2], 'POST');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — modifier_variable : hors ligne délègue à forgeApplyVariableActionOffline, en ligne POST vers flowNodeRunVariable', async () => {
|
||||||
|
global.window.FORGE_OFFLINE = true;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'modifier_variable', id: 22 });
|
||||||
|
assert.deepEqual(calls, [['forgeApplyVariableActionOffline', 22], ['refreshRuntimeData']]);
|
||||||
|
});
|
||||||
|
global.window.FORGE_OFFLINE = false;
|
||||||
|
await withActionNodeGlobals(async (calls) => {
|
||||||
|
await runActionNode({ action_type: 'modifier_variable', id: 999999 });
|
||||||
|
assert.equal(calls[0][1], '/run-variable/999999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — modifier_score : hors ligne délègue à forgeApplyScoreActionOffline, en ligne POST vers flowNodeRunScore', async () => {
|
||||||
|
global.window.FORGE_OFFLINE = true;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'modifier_score', id: 33 });
|
||||||
|
assert.deepEqual(calls, [['forgeApplyScoreActionOffline', 33], ['refreshRuntimeData']]);
|
||||||
|
});
|
||||||
|
global.window.FORGE_OFFLINE = false;
|
||||||
|
await withActionNodeGlobals(async (calls) => {
|
||||||
|
await runActionNode({ action_type: 'modifier_score', id: 999999 });
|
||||||
|
assert.equal(calls[0][1], '/run-score/999999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — definir_statut_partie : hors ligne délègue à forgeApplyStatusActionOffline, en ligne POST vers flowNodeRunStatus', async () => {
|
||||||
|
global.window.FORGE_OFFLINE = true;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'definir_statut_partie', id: 44 });
|
||||||
|
assert.deepEqual(calls, [['forgeApplyStatusActionOffline', 44], ['refreshRuntimeData']]);
|
||||||
|
});
|
||||||
|
global.window.FORGE_OFFLINE = false;
|
||||||
|
await withActionNodeGlobals(async (calls) => {
|
||||||
|
await runActionNode({ action_type: 'definir_statut_partie', id: 999999 });
|
||||||
|
assert.equal(calls[0][1], '/run-status/999999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — ajouter_ligne : hors ligne mémorise l\'id renvoyé, en ligne POST puis mémorise json.row_id', async () => {
|
||||||
|
global.window.FORGE_OFFLINE = true;
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'ajouter_ligne', id: 55 });
|
||||||
|
assert.equal(global.window.lastInsertedRowId, 4242);
|
||||||
|
assert.deepEqual(calls, [['forgeApplyAddRowActionOffline', 55], ['refreshRuntimeData']]);
|
||||||
|
});
|
||||||
|
global.window.FORGE_OFFLINE = false;
|
||||||
|
await withActionNodeGlobals(async (calls) => {
|
||||||
|
await runActionNode({ action_type: 'ajouter_ligne', id: 999999 });
|
||||||
|
assert.equal(global.window.lastInsertedRowId, 4343);
|
||||||
|
assert.equal(calls[0][1], '/run-add-row/999999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — jouer_son : joue le fichier audio (fire-and-forget), aucun effet sans data_value', () => {
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'jouer_son', data_value: '/bip.mp3' });
|
||||||
|
assert.deepEqual(calls, [['Audio', '/bip.mp3']]);
|
||||||
|
});
|
||||||
|
withActionNodeGlobals((calls) => {
|
||||||
|
runActionNode({ action_type: 'jouer_son' });
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runActionNode — action_type inconnu (ou "rien") : ne fait rien, renvoie une Promise résolue', async () => {
|
||||||
|
const result = runActionNode({ action_type: 'rien' });
|
||||||
|
assert.ok(result instanceof Promise);
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => {
|
test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => {
|
||||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
||||||
const el = { style: { width: '40px', height: '40px' } };
|
const el = { style: { width: '40px', height: '40px' } };
|
||||||
@@ -147,3 +543,27 @@ test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène
|
|||||||
assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà
|
assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runActionNode — jouer_animation_sprite avec data_value JSON invalide : avertit en console, ne casse pas le rendu (bug S2486 : exception avalée en silence auparavant)', () => {
|
||||||
|
const el = fakeImg();
|
||||||
|
const originalDocument = global.document;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const warnCalls = [];
|
||||||
|
global.document = { querySelector: () => el };
|
||||||
|
console.warn = (...args) => { warnCalls.push(args); };
|
||||||
|
try {
|
||||||
|
const result = runActionNode({
|
||||||
|
action_type: 'jouer_animation_sprite',
|
||||||
|
target_element_id: 'obj1',
|
||||||
|
data_value: '{not valid json',
|
||||||
|
});
|
||||||
|
assert.ok(result instanceof Promise, 'doit quand même renvoyer une Promise, comme un data_value valide');
|
||||||
|
assert.equal(el.src, '', 'aucune frame ne doit être posée — rendu inchangé, pas de crash');
|
||||||
|
assert.equal(warnCalls.length, 1);
|
||||||
|
assert.match(warnCalls[0][0], /configuration sprite invalide/);
|
||||||
|
} finally {
|
||||||
|
global.document = originalDocument;
|
||||||
|
console.warn = originalWarn;
|
||||||
|
stopAllSpriteAnimations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ function fakeEl(style) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
|
||||||
|
// Getter (pas une valeur figée à la construction) : recalculé à
|
||||||
|
// chaque lecture, comme le vrai `.dataset`, puisque `_objectId` est
|
||||||
|
// posé APRÈS coup par les tests (voir plus bas) — un `dataset: {}`
|
||||||
|
// gelé au moment de fakeEl() serait toujours vide.
|
||||||
|
get dataset() { return { objectId: this._objectId }; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function fakeParentEl() {
|
|||||||
appendChild(child) { children.push(child); },
|
appendChild(child) { children.push(child); },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
const m = sel.match(/data-for-object-id="(\d+)"/);
|
const m = sel.match(/data-for-object-id="(\d+)"/);
|
||||||
return m ? children.find((c) => c._forObjectId === m[1]) : undefined;
|
return m ? children.find((c) => c.dataset.forObjectId === m[1]) : undefined;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,12 +53,24 @@ function fakeEl(parentEl) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un vrai `.dataset` (DOMStringMap) convertit toujours la valeur assignée
|
||||||
|
// en chaîne — reproduit ici via un Proxy, sinon `bubble.dataset.forObjectId
|
||||||
|
// = objectId` avec un objectId numérique (cas réel : obj.id vient de la
|
||||||
|
// base) resterait un number, et ne correspondrait plus jamais à la
|
||||||
|
// capture de regex (toujours une string) dans fakeParentEl.querySelector().
|
||||||
|
function makeDataset() {
|
||||||
|
const store = {};
|
||||||
|
return new Proxy(store, {
|
||||||
|
set(target, key, value) { target[key] = String(value); return true; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function fakeBubbleEl() {
|
function fakeBubbleEl() {
|
||||||
return {
|
return {
|
||||||
className: 'collisionInteractBubble',
|
className: 'collisionInteractBubble',
|
||||||
style: {},
|
style: {},
|
||||||
textContent: '',
|
textContent: '',
|
||||||
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
|
dataset: makeDataset(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ const {
|
|||||||
forgeQuizResultQuit,
|
forgeQuizResultQuit,
|
||||||
} = require('../dialogue-box-controller.js');
|
} = require('../dialogue-box-controller.js');
|
||||||
|
|
||||||
|
// Un vrai `.dataset` (DOMStringMap) lit/écrit les MÊMES attributs qu'un
|
||||||
|
// `getAttribute('data-foo-bar')`/`setAttribute(...)`, juste sous forme
|
||||||
|
// camelCase (`dataset.fooBar`) — ce Proxy reflète les deux dans le MÊME
|
||||||
|
// objet `attrs` passé en argument, pour que les mocks déjà construits par
|
||||||
|
// clé kebab-case (ex. `{ 'data-timer-mode': 'question' }`, déjà utilisé
|
||||||
|
// par plusieurs tests de ce fichier) continuent de marcher sans y
|
||||||
|
// toucher, tout en exposant `.dataset` comme le vrai DOM (voir
|
||||||
|
// dialogue-box-controller.js après le lot 4 "modernisation JS", S7761).
|
||||||
|
function kebabDataKey(camelProp) {
|
||||||
|
return `data-${camelProp.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`;
|
||||||
|
}
|
||||||
|
function makeDatasetFromAttrs(attrs) {
|
||||||
|
return new Proxy({}, {
|
||||||
|
get(_, prop) { return attrs[kebabDataKey(prop)]; },
|
||||||
|
set(_, prop, value) { attrs[kebabDataKey(prop)] = String(value); return true; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function fakeWidget(objectId) {
|
function fakeWidget(objectId) {
|
||||||
const roles = {
|
const roles = {
|
||||||
header: { textContent: '' },
|
header: { textContent: '' },
|
||||||
@@ -27,8 +45,10 @@ function fakeWidget(objectId) {
|
|||||||
footer: { innerHTML: '' },
|
footer: { innerHTML: '' },
|
||||||
};
|
};
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
|
const attrs = { 'data-object-id': String(objectId) };
|
||||||
return {
|
return {
|
||||||
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
|
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||||
|
dataset: makeDatasetFromAttrs(attrs),
|
||||||
classList: {
|
classList: {
|
||||||
add: (c) => classes.add(c),
|
add: (c) => classes.add(c),
|
||||||
remove: (c) => classes.delete(c),
|
remove: (c) => classes.delete(c),
|
||||||
@@ -57,7 +77,7 @@ function fakeWidget(objectId) {
|
|||||||
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
|
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
|
||||||
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
|
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
|
||||||
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
|
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
|
||||||
const screenEl = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) };
|
const screenEl = { dataset: { screenId: '7' } };
|
||||||
const attrs = {};
|
const attrs = {};
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
const bodyChildren = [];
|
const bodyChildren = [];
|
||||||
@@ -65,7 +85,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
|||||||
const widget = {
|
const widget = {
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
||||||
setAttribute: (k, v) => { attrs[k] = v; },
|
dataset: makeDatasetFromAttrs(attrs),
|
||||||
classList: { add: (c) => classes.add(c) },
|
classList: { add: (c) => classes.add(c) },
|
||||||
};
|
};
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
@@ -74,7 +94,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
|||||||
forgeEscapeCameraForFullscreen(widget);
|
forgeEscapeCameraForFullscreen(widget);
|
||||||
assert.equal(bodyChildren[0], widget);
|
assert.equal(bodyChildren[0], widget);
|
||||||
assert.equal(widget.parentNode, body);
|
assert.equal(widget.parentNode, body);
|
||||||
assert.equal(attrs['data-owner-screen-id'], '7');
|
assert.equal(widget.dataset.ownerScreenId, '7');
|
||||||
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
|
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
|
||||||
} finally { global.document = previousDoc; }
|
} finally { global.document = previousDoc; }
|
||||||
});
|
});
|
||||||
@@ -288,6 +308,7 @@ function fakeQuizChoiceBtn() {
|
|||||||
|
|
||||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||||
attrs = attrs || {};
|
attrs = attrs || {};
|
||||||
|
attrs['data-object-id'] = String(objectId);
|
||||||
const roles = {
|
const roles = {
|
||||||
header: { textContent: '' },
|
header: { textContent: '' },
|
||||||
question: { textContent: '' },
|
question: { textContent: '' },
|
||||||
@@ -312,13 +333,10 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
|||||||
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
||||||
// un widget "posé sur l'écran courant" est toujours, au départ,
|
// un widget "posé sur l'écran courant" est toujours, au départ,
|
||||||
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
|
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
|
||||||
closest: (sel) => (sel === '.playScreen' ? { getAttribute: (a) => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
|
closest: (sel) => (sel === '.playScreen' ? { dataset: { screenId: String(window.currentScreenId) } } : null),
|
||||||
getAttribute: (attr) => {
|
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||||
if (attr === 'data-object-id') return String(objectId);
|
|
||||||
if (attr in attrs) return attrs[attr];
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
setAttribute: (k, v) => { attrs[k] = v; },
|
setAttribute: (k, v) => { attrs[k] = v; },
|
||||||
|
dataset: makeDatasetFromAttrs(attrs),
|
||||||
classList: {
|
classList: {
|
||||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
||||||
@@ -600,7 +618,6 @@ test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jama
|
|||||||
|
|
||||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||||
const dialogueWidget = fakeWidget(42);
|
const dialogueWidget = fakeWidget(42);
|
||||||
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
|
|
||||||
const quizWidget = fakeQuizWidget(99, 2);
|
const quizWidget = fakeQuizWidget(99, 2);
|
||||||
const previousDoc = global.document;
|
const previousDoc = global.document;
|
||||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||||
@@ -948,7 +965,7 @@ test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "t
|
|||||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||||
const dialogueWidget = fakeWidget(42);
|
const dialogueWidget = fakeWidget(42);
|
||||||
const quizWidget = {
|
const quizWidget = {
|
||||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
dataset: { objectId: '99' },
|
||||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
@@ -979,7 +996,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
|||||||
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
|
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
|
||||||
const dialogueWidget = fakeWidget(42);
|
const dialogueWidget = fakeWidget(42);
|
||||||
const quizWidget = {
|
const quizWidget = {
|
||||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
dataset: { objectId: '99' },
|
||||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function fakeMangaWidget() {
|
|||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
return {
|
return {
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||||
|
dataset: { objectId: '42' },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
if (sel === '[data-quiz-role="header"]') return header;
|
if (sel === '[data-quiz-role="header"]') return header;
|
||||||
if (sel === '[data-manga-role="progress"]') return progress;
|
if (sel === '[data-manga-role="progress"]') return progress;
|
||||||
@@ -139,6 +140,7 @@ function fakeClassiqueWidget() {
|
|||||||
const progress = { innerHTML: '' };
|
const progress = { innerHTML: '' };
|
||||||
return {
|
return {
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||||
|
dataset: { objectId: '42' },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
if (sel === '[data-quiz-role="header"]') return header;
|
if (sel === '[data-quiz-role="header"]') return header;
|
||||||
if (sel === '[data-classique-role="progress"]') return progress;
|
if (sel === '[data-classique-role="progress"]') return progress;
|
||||||
@@ -182,6 +184,7 @@ function fakeClassiqueWidgetForResult() {
|
|||||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||||
return {
|
return {
|
||||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||||
|
dataset: { objectId: '42' },
|
||||||
querySelector(sel) {
|
querySelector(sel) {
|
||||||
if (sel === '.classiqueContent') return content;
|
if (sel === '.classiqueContent') return content;
|
||||||
if (sel === '[data-classique-role="result"]') return resultZone;
|
if (sel === '[data-classique-role="result"]') return resultZone;
|
||||||
|
|||||||
+363
-255
@@ -20,104 +20,130 @@ const ELEMENT_PROPERTY_CSS = {
|
|||||||
pos_y: { css: 'top', suffix: '%' },
|
pos_y: { css: 'top', suffix: '%' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||||
|
// binding vers un champ d'objet ici (contrairement à
|
||||||
|
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme pour
|
||||||
|
// les autres réglages ci-dessous, cible n'importe quel élément — pointer
|
||||||
|
// ceci vers un conteneur remplacerait tout son contenu imbriqué, à éviter.
|
||||||
|
function applyContenuProperty(targetEl, value) {
|
||||||
|
targetEl.textContent = value == null ? '' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||||
|
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||||
|
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans poser
|
||||||
|
// de bordure colorée togglée à la main — utile pour un mentor qui attire
|
||||||
|
// l'attention du joueur vers l'élément à toucher ensuite.
|
||||||
|
function applySurbrillanceProperty(targetEl, value) {
|
||||||
|
if (value === 'toggle') {
|
||||||
|
targetEl.classList.toggle('forgeHighlight');
|
||||||
|
} else if (value === 'off') {
|
||||||
|
targetEl.classList.remove('forgeHighlight');
|
||||||
|
} else {
|
||||||
|
targetEl.classList.add('forgeHighlight');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT de
|
||||||
|
// "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||||
|
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au clic"
|
||||||
|
// éventuellement posé sur cet élément ne se redéclenche plus).
|
||||||
|
function applyDesactiveProperty(targetEl, value) {
|
||||||
|
if (value === 'toggle') {
|
||||||
|
targetEl.classList.toggle('forgeDisabled');
|
||||||
|
} else if (value === 'off') {
|
||||||
|
targetEl.classList.remove('forgeDisabled');
|
||||||
|
} else {
|
||||||
|
targetEl.classList.add('forgeDisabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVisibiliteProperty(targetEl, value) {
|
||||||
|
if (value === 'toggle') {
|
||||||
|
const hidden = targetEl.style.display === 'none';
|
||||||
|
targetEl.style.display = hidden ? '' : 'none';
|
||||||
|
targetEl.style.visibility = '';
|
||||||
|
} else if (value === 'masque') {
|
||||||
|
targetEl.style.display = 'none';
|
||||||
|
} else if (value === 'invisible') {
|
||||||
|
targetEl.style.display = '';
|
||||||
|
targetEl.style.visibility = 'hidden';
|
||||||
|
} else {
|
||||||
|
targetEl.style.display = '';
|
||||||
|
targetEl.style.visibility = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||||
|
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue de
|
||||||
|
// dos" pour chaque sens de déplacement.
|
||||||
|
function applyOrientationProperty(targetEl, value) {
|
||||||
|
if (value === 'toggle') {
|
||||||
|
targetEl.classList.toggle('forgeFlipped');
|
||||||
|
} else if (value === 'gauche') {
|
||||||
|
targetEl.classList.add('forgeFlipped');
|
||||||
|
} else {
|
||||||
|
targetEl.classList.remove('forgeFlipped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||||
|
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||||
|
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||||
|
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||||
|
// toujours au même endroit.
|
||||||
|
function applyRelativePositionProperty(targetEl, property, value) {
|
||||||
|
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||||
|
const delta = Number.parseFloat(value);
|
||||||
|
if (Number.isNaN(delta)) return;
|
||||||
|
const current = Number.parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||||
|
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||||
|
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||||
|
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||||
|
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||||
|
// 1re valeur si l'écran est rechargé.
|
||||||
|
function applyToggleValueProperty(targetEl, property, spec, parts) {
|
||||||
|
const key = `toggle${property}`;
|
||||||
|
const atSecond = targetEl.dataset[key] === '1';
|
||||||
|
const next = atSecond ? parts[0] : parts[1];
|
||||||
|
targetEl.style[spec.css] = next + spec.suffix;
|
||||||
|
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
function applyElementProperty(targetEl, property, value) {
|
function applyElementProperty(targetEl, property, value) {
|
||||||
if (property === 'contenu') {
|
if (property === 'contenu') {
|
||||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
applyContenuProperty(targetEl, value);
|
||||||
// binding vers un champ d'objet ici (contrairement à
|
|
||||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
|
||||||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
|
||||||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
|
||||||
// imbriqué, à éviter.
|
|
||||||
targetEl.textContent = value == null ? '' : value;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'surbrillance') {
|
if (property === 'surbrillance') {
|
||||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
applySurbrillanceProperty(targetEl, value);
|
||||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
|
||||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
|
||||||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
|
||||||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
|
||||||
if (value === 'toggle') {
|
|
||||||
targetEl.classList.toggle('forgeHighlight');
|
|
||||||
} else if (value === 'off') {
|
|
||||||
targetEl.classList.remove('forgeHighlight');
|
|
||||||
} else {
|
|
||||||
targetEl.classList.add('forgeHighlight');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'desactive') {
|
if (property === 'desactive') {
|
||||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
applyDesactiveProperty(targetEl, value);
|
||||||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
|
||||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
|
||||||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
|
||||||
if (value === 'toggle') {
|
|
||||||
targetEl.classList.toggle('forgeDisabled');
|
|
||||||
} else if (value === 'off') {
|
|
||||||
targetEl.classList.remove('forgeDisabled');
|
|
||||||
} else {
|
|
||||||
targetEl.classList.add('forgeDisabled');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'visibilite') {
|
if (property === 'visibilite') {
|
||||||
if (value === 'toggle') {
|
applyVisibiliteProperty(targetEl, value);
|
||||||
const hidden = targetEl.style.display === 'none';
|
|
||||||
targetEl.style.display = hidden ? '' : 'none';
|
|
||||||
targetEl.style.visibility = '';
|
|
||||||
} else if (value === 'masque') {
|
|
||||||
targetEl.style.display = 'none';
|
|
||||||
} else if (value === 'invisible') {
|
|
||||||
targetEl.style.display = '';
|
|
||||||
targetEl.style.visibility = 'hidden';
|
|
||||||
} else {
|
|
||||||
targetEl.style.display = '';
|
|
||||||
targetEl.style.visibility = '';
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'orientation') {
|
if (property === 'orientation') {
|
||||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
applyOrientationProperty(targetEl, value);
|
||||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
|
|
||||||
// de dos" pour chaque sens de déplacement.
|
|
||||||
if (value === 'toggle') {
|
|
||||||
targetEl.classList.toggle('forgeFlipped');
|
|
||||||
} else if (value === 'gauche') {
|
|
||||||
targetEl.classList.add('forgeFlipped');
|
|
||||||
} else {
|
|
||||||
targetEl.classList.remove('forgeFlipped');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
applyRelativePositionProperty(targetEl, property, value);
|
||||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
|
||||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
|
||||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
|
||||||
// toujours au même endroit.
|
|
||||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
|
||||||
const delta = parseFloat(value);
|
|
||||||
if (Number.isNaN(delta)) return;
|
|
||||||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
|
||||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||||
if (!spec || value === undefined || value === null) return;
|
if (!spec || value === undefined || value === null) return;
|
||||||
|
|
||||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
if (typeof value === 'string' && value.includes('||')) {
|
||||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
applyToggleValueProperty(targetEl, property, spec, value.split('||'));
|
||||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
|
||||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
|
||||||
// 1re valeur si l'écran est rechargé.
|
|
||||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
|
||||||
const parts = value.split('||');
|
|
||||||
const key = `toggle${property}`;
|
|
||||||
const atSecond = targetEl.dataset[key] === '1';
|
|
||||||
const next = atSecond ? parts[0] : parts[1];
|
|
||||||
targetEl.style[spec.css] = next + spec.suffix;
|
|
||||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +164,8 @@ function applyElementProperty(targetEl, property, value) {
|
|||||||
// risquer un blocage à 0.
|
// risquer un blocage à 0.
|
||||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
if (screenData?.scene_width == null) return rawValue;
|
||||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
const size = Number.parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||||
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||||||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||||||
@@ -155,197 +181,278 @@ function clampSceneObjectPosition(targetEl, css, rawValue) {
|
|||||||
function applyObjectProperty(targetEl, property, value) {
|
function applyObjectProperty(targetEl, property, value) {
|
||||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||||
const delta = parseFloat(value);
|
const delta = Number.parseFloat(value);
|
||||||
if (Number.isNaN(delta)) return;
|
if (Number.isNaN(delta)) return;
|
||||||
const current = parseFloat(targetEl.style[css]) || 0;
|
const current = Number.parseFloat(targetEl.style[css]) || 0;
|
||||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (property === 'pos_x' || property === 'pos_y') {
|
if (property === 'pos_x' || property === 'pos_y') {
|
||||||
if (value === undefined || value === null) return;
|
if (value === undefined || value === null) return;
|
||||||
const css = property === 'pos_x' ? 'left' : 'top';
|
const css = property === 'pos_x' ? 'left' : 'top';
|
||||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0)}px`;
|
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, Number.parseFloat(value) || 0)}px`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyElementProperty(targetEl, property, value);
|
applyElementProperty(targetEl, property, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runActionNode(node) {
|
function runEcranSuivantAction() {
|
||||||
if (node.action_type === 'ecran_suivant') {
|
const idx = screenIndexById(window.currentScreenId);
|
||||||
const idx = screenIndexById(window.currentScreenId);
|
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'ecran_precedent') {
|
|
||||||
const idx = screenIndexById(window.currentScreenId);
|
|
||||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'aller_a' && node.target_screen_id) {
|
|
||||||
showScreen(node.target_screen_id);
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
|
||||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
|
||||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
|
||||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
|
||||||
// précise.
|
|
||||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
|
||||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
|
||||||
}
|
|
||||||
showScreen(node.target_screen_id);
|
|
||||||
applyOpenRowBindings();
|
|
||||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
|
||||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
|
||||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
|
||||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
|
||||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
|
||||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
|
||||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
|
||||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
|
||||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
|
||||||
bindClicks();
|
|
||||||
bindHoverTexts();
|
|
||||||
bindHoverTriggers();
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'modifier_element' && node.target_element_id) {
|
|
||||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
|
||||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
|
||||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
|
||||||
// "modifier_element" ci-dessus, mais en pixels (voir
|
|
||||||
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
|
||||||
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
|
||||||
// render_scene_object.py), donc le même sélecteur suffit.
|
|
||||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
|
||||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
|
||||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
|
||||||
// signaler l'événement, jamais choisir un élément — c'est à
|
|
||||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
|
||||||
// faire, avec ses propres réglages habituels.
|
|
||||||
window.dispatchGameEvent(node.target_custom_event_id);
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
|
||||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
|
||||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
|
||||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
|
||||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
|
||||||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
|
||||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
|
||||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
|
||||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
|
||||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
|
||||||
const allElements = screenData ? screenData.elements : [];
|
|
||||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
|
||||||
if (target) {
|
|
||||||
const parentKey = target.parent_id || null;
|
|
||||||
allElements
|
|
||||||
.filter((e) => (e.parent_id || null) === parentKey)
|
|
||||||
.forEach((e) => {
|
|
||||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
|
||||||
if (!el) return;
|
|
||||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'attendre') {
|
|
||||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
|
||||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
|
||||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
|
||||||
// quelques secondes après l'affichage de l'écran, sans action du
|
|
||||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
|
||||||
const seconds = parseFloat(node.data_value);
|
|
||||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
|
||||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
||||||
} if (node.action_type === 'modifier_donnee') {
|
|
||||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
|
|
||||||
// aucun serveur disponible dans le paquet exporté — la même mutation
|
|
||||||
// s'applique directement sur gameData, en mémoire, dans le navigateur.
|
|
||||||
if (window.FORGE_OFFLINE) {
|
|
||||||
forgeApplyDataActionOffline(gameData, node);
|
|
||||||
return refreshRuntimeData();
|
|
||||||
}
|
|
||||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
|
||||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
|
||||||
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
|
||||||
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
|
|
||||||
// sinon, donc toujours transmis sans condition ici.
|
|
||||||
return fetch(runDataUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(() => refreshRuntimeData());
|
|
||||||
} if (node.action_type === 'modifier_variable') {
|
|
||||||
if (window.FORGE_OFFLINE) {
|
|
||||||
forgeApplyVariableActionOffline(gameData, node);
|
|
||||||
return refreshRuntimeData();
|
|
||||||
}
|
|
||||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
|
||||||
return fetch(runVarUrl, { method: 'POST' })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(() => refreshRuntimeData());
|
|
||||||
} if (node.action_type === 'modifier_score') {
|
|
||||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
|
||||||
// apply_score_action.py) : même patron que "Modifier une variable
|
|
||||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
|
||||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
|
||||||
if (window.FORGE_OFFLINE) {
|
|
||||||
forgeApplyScoreActionOffline(gameData, node);
|
|
||||||
return refreshRuntimeData();
|
|
||||||
}
|
|
||||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
|
||||||
return fetch(runScoreUrl, { method: 'POST' })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(() => refreshRuntimeData());
|
|
||||||
} if (node.action_type === 'definir_statut_partie') {
|
|
||||||
if (window.FORGE_OFFLINE) {
|
|
||||||
forgeApplyStatusActionOffline(gameData, node);
|
|
||||||
return refreshRuntimeData();
|
|
||||||
}
|
|
||||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
|
||||||
return fetch(runStatusUrl, { method: 'POST' })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then(() => refreshRuntimeData());
|
|
||||||
} if (node.action_type === 'ajouter_ligne') {
|
|
||||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
|
|
||||||
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
|
|
||||||
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
|
|
||||||
// "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
|
||||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
|
||||||
if (window.FORGE_OFFLINE) {
|
|
||||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
|
||||||
return refreshRuntimeData();
|
|
||||||
}
|
|
||||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
|
||||||
return fetch(runAddRowUrl, { method: 'POST' })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((json) => {
|
|
||||||
window.lastInsertedRowId = json.row_id;
|
|
||||||
return refreshRuntimeData();
|
|
||||||
});
|
|
||||||
} if (node.action_type === 'jouer_son') {
|
|
||||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
|
|
||||||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
|
||||||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
|
||||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
|
||||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {});
|
|
||||||
return Promise.resolve();
|
|
||||||
} if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
|
||||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
|
||||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
|
||||||
if (targetEl) {
|
|
||||||
let spriteData = {};
|
|
||||||
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
|
||||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
// 'rien' : aucune action volontairement.
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runEcranPrecedentAction() {
|
||||||
|
const idx = screenIndexById(window.currentScreenId);
|
||||||
|
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAllerAAction(node) {
|
||||||
|
if (!node.target_screen_id) return Promise.resolve();
|
||||||
|
showScreen(node.target_screen_id);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette action
|
||||||
|
// (voir bindClicks dans triggers.js) puis affiche l'écran de détail avec
|
||||||
|
// ses placeholders "champ" résolus contre cette ligne précise.
|
||||||
|
function runOuvrirLigneAction(node) {
|
||||||
|
if (!node.target_screen_id) return Promise.resolve();
|
||||||
|
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||||
|
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||||
|
}
|
||||||
|
showScreen(node.target_screen_id);
|
||||||
|
applyOpenRowBindings();
|
||||||
|
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||||
|
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||||
|
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||||
|
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||||
|
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||||
|
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||||
|
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||||
|
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||||
|
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||||
|
bindClicks();
|
||||||
|
bindHoverTexts();
|
||||||
|
bindHoverTriggers();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runModifierElementAction(node) {
|
||||||
|
if (!node.target_element_id) return Promise.resolve();
|
||||||
|
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||||
|
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||||
|
// "modifier_element" ci-dessus, mais en pixels (voir applyObjectProperty()
|
||||||
|
// plus bas) plutôt qu'en %. data-element-id est posé sur CHAQUE objet de
|
||||||
|
// scène en plus de data-object-id (voir render_scene_object.py), donc le
|
||||||
|
// même sélecteur suffit.
|
||||||
|
function runModifierObjetSceneAction(node) {
|
||||||
|
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||||
|
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||||
|
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification pure (voir screens/custom_events/) : ne fait que signaler
|
||||||
|
// l'événement, jamais choisir un élément — c'est à l'écouteur (déclencheur
|
||||||
|
// → condition → action) de décider quoi faire, avec ses propres réglages
|
||||||
|
// habituels.
|
||||||
|
function runDeclencherEvenementAction(node) {
|
||||||
|
if (!node.target_custom_event_id) return Promise.resolve();
|
||||||
|
window.dispatchGameEvent(node.target_custom_event_id);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||||
|
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id, y
|
||||||
|
// compris "aucun parent" = les autres éléments de premier niveau de
|
||||||
|
// l'écran) en une seule action, plutôt qu'une action "Masquer" par frère.
|
||||||
|
// screenData.elements contient TOUS les éléments de l'écran (imbriqués
|
||||||
|
// compris, voir list_elements côté serveur), donc ça fonctionne aussi
|
||||||
|
// bien pour des onglets imbriqués dans un même conteneur que pour des
|
||||||
|
// "vues" posées directement sur l'écran.
|
||||||
|
function runActiverOngletAction(node) {
|
||||||
|
if (!node.target_element_id) return Promise.resolve();
|
||||||
|
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||||
|
const allElements = screenData ? screenData.elements : [];
|
||||||
|
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||||
|
if (target) {
|
||||||
|
const parentKey = target.parent_id || null;
|
||||||
|
allElements
|
||||||
|
.filter((e) => (e.parent_id || null) === parentKey)
|
||||||
|
.forEach((e) => {
|
||||||
|
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||||
|
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||||
|
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||||
|
// quelques secondes après l'affichage de l'écran, sans action du joueur
|
||||||
|
// (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||||
|
function runAttendreAction(node) {
|
||||||
|
const seconds = Number.parseFloat(node.data_value);
|
||||||
|
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||||
|
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) : aucun
|
||||||
|
// serveur disponible dans le paquet exporté — la même mutation s'applique
|
||||||
|
// directement sur gameData, en mémoire, dans le navigateur.
|
||||||
|
function runModifierDonneeAction(node) {
|
||||||
|
if (window.FORGE_OFFLINE) {
|
||||||
|
forgeApplyDataActionOffline(gameData, node);
|
||||||
|
return refreshRuntimeData();
|
||||||
|
}
|
||||||
|
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||||
|
// clicked_row_id/last_inserted_row_id : utilisés côté serveur SEULEMENT
|
||||||
|
// si ce nœud vise respectivement CLICKED_ROW_ID ou LAST_INSERTED_ROW_ID
|
||||||
|
// (voir flow_node_run_data.py) — sans effet sinon, donc toujours
|
||||||
|
// transmis sans condition ici.
|
||||||
|
return fetch(runDataUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(() => refreshRuntimeData());
|
||||||
|
}
|
||||||
|
|
||||||
|
function runModifierVariableAction(node) {
|
||||||
|
if (window.FORGE_OFFLINE) {
|
||||||
|
forgeApplyVariableActionOffline(gameData, node);
|
||||||
|
return refreshRuntimeData();
|
||||||
|
}
|
||||||
|
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||||
|
return fetch(runVarUrl, { method: 'POST' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(() => refreshRuntimeData());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||||
|
// apply_score_action.py) : même patron que "Modifier une variable
|
||||||
|
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||||
|
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||||
|
function runModifierScoreAction(node) {
|
||||||
|
if (window.FORGE_OFFLINE) {
|
||||||
|
forgeApplyScoreActionOffline(gameData, node);
|
||||||
|
return refreshRuntimeData();
|
||||||
|
}
|
||||||
|
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||||
|
return fetch(runScoreUrl, { method: 'POST' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(() => refreshRuntimeData());
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDefinirStatutPartieAction(node) {
|
||||||
|
if (window.FORGE_OFFLINE) {
|
||||||
|
forgeApplyStatusActionOffline(gameData, node);
|
||||||
|
return refreshRuntimeData();
|
||||||
|
}
|
||||||
|
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||||
|
return fetch(runStatusUrl, { method: 'POST' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(() => refreshRuntimeData());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire hors
|
||||||
|
// ligne) ; son id devient window.lastInsertedRowId pour que les nœuds
|
||||||
|
// "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕
|
||||||
|
// Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||||
|
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||||
|
function runAjouterLigneAction(node) {
|
||||||
|
if (window.FORGE_OFFLINE) {
|
||||||
|
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||||
|
return refreshRuntimeData();
|
||||||
|
}
|
||||||
|
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||||
|
return fetch(runAddRowUrl, { method: 'POST' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((json) => {
|
||||||
|
window.lastInsertedRowId = json.row_id;
|
||||||
|
return refreshRuntimeData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la musique
|
||||||
|
// de fond de l'écran (runScreenBackgroundMusic() dans screens.js, qui
|
||||||
|
// elle boucle et s'arrête au changement d'écran). Fire-and-forget : ne
|
||||||
|
// bloque jamais la suite du graphe.
|
||||||
|
function runJouerSonAction(node) {
|
||||||
|
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runJouerAnimationSpriteAction(node) {
|
||||||
|
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||||
|
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||||
|
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||||
|
if (targetEl) {
|
||||||
|
let spriteData = {};
|
||||||
|
try {
|
||||||
|
spriteData = JSON.parse(node.data_value || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||||
|
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||||
|
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||||
|
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||||
|
// reste silencieusement absente (spriteData reste {}).
|
||||||
|
console.warn('configuration sprite invalide', e);
|
||||||
|
}
|
||||||
|
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch par action_type — chaque fonction ci-dessus vérifie ELLE-MÊME
|
||||||
|
// ses conditions supplémentaires (ex. target_screen_id présent) et
|
||||||
|
// renvoie une Promise résolue sans effet si elles ne sont pas remplies ;
|
||||||
|
// ce dispatcher n'a donc plus qu'une seule décision (la présence d'un
|
||||||
|
// handler), au lieu d'empiler ici les conditions de chaque branche
|
||||||
|
// (c'était la source du score S3776 restant à 23 après la 1re extraction
|
||||||
|
// en sous-fonctions — un enchaînement de if, même plat, coûte un point
|
||||||
|
// par condition ET par opérateur booléen combiné).
|
||||||
|
// Clés QUOTÉES délibérément (pas juste un style) : plusieurs tests Python
|
||||||
|
// vérifient la présence du littéral quoté ("'jouer_son'" in js, etc.,
|
||||||
|
// voir tests/test_sound.py, tests/test_sprite_animation.py,
|
||||||
|
// tests/test_flow.py) comme preuve que ce action_type est bien implémenté
|
||||||
|
// côté runtime — une clé nue (jouer_son: ...) fait disparaître ce
|
||||||
|
// littéral du bundle JS sans rien changer au comportement, mais casse ces
|
||||||
|
// tests. Gardé quoté pour rester détectable de la même façon.
|
||||||
|
const ACTION_NODE_HANDLERS = {
|
||||||
|
ecran_suivant: runEcranSuivantAction,
|
||||||
|
ecran_precedent: runEcranPrecedentAction,
|
||||||
|
aller_a: runAllerAAction,
|
||||||
|
'ouvrir_ligne': runOuvrirLigneAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||||
|
modifier_element: runModifierElementAction,
|
||||||
|
modifier_objet_scene: runModifierObjetSceneAction,
|
||||||
|
declencher_evenement: runDeclencherEvenementAction,
|
||||||
|
activer_onglet: runActiverOngletAction,
|
||||||
|
attendre: runAttendreAction,
|
||||||
|
modifier_donnee: runModifierDonneeAction,
|
||||||
|
modifier_variable: runModifierVariableAction,
|
||||||
|
modifier_score: runModifierScoreAction,
|
||||||
|
definir_statut_partie: runDefinirStatutPartieAction,
|
||||||
|
ajouter_ligne: runAjouterLigneAction,
|
||||||
|
'jouer_son': runJouerSonAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||||
|
'jouer_animation_sprite': runJouerAnimationSpriteAction, // eslint-disable-line quote-props -- idem
|
||||||
|
};
|
||||||
|
|
||||||
|
function runActionNode(node) {
|
||||||
|
const handler = ACTION_NODE_HANDLERS[node.action_type];
|
||||||
|
// 'rien' (ou tout action_type inconnu) : aucune action volontairement.
|
||||||
|
return handler ? handler(node) : Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||||||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||||||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
||||||
@@ -373,7 +480,7 @@ const activeSpriteAnimations = new Map();
|
|||||||
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||||||
// les nœuds/clips déjà créés avant ce changement.
|
// les nœuds/clips déjà créés avant ce changement.
|
||||||
function resolveSpriteFrames(elementId, spriteData) {
|
function resolveSpriteFrames(elementId, spriteData) {
|
||||||
const byAnimation = (gameData.personnage_animations || {})[elementId];
|
const byAnimation = gameData.personnage_animations?.[elementId];
|
||||||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||||||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||||||
}
|
}
|
||||||
@@ -409,7 +516,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
|||||||
const loop = !!spriteData.loop;
|
const loop = !!spriteData.loop;
|
||||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||||
const active = activeSpriteAnimations.get(targetEl);
|
const active = activeSpriteAnimations.get(targetEl);
|
||||||
if (active && active.signature === signature) return;
|
if (active?.signature === signature) return;
|
||||||
if (active && !active.loop) return;
|
if (active && !active.loop) return;
|
||||||
if (active) {
|
if (active) {
|
||||||
clearInterval(active.intervalId);
|
clearInterval(active.intervalId);
|
||||||
@@ -452,6 +559,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
runSpriteAnimation,
|
runSpriteAnimation,
|
||||||
stopAllSpriteAnimations,
|
stopAllSpriteAnimations,
|
||||||
activeSpriteAnimations,
|
activeSpriteAnimations,
|
||||||
|
applyElementProperty,
|
||||||
applyObjectProperty,
|
applyObjectProperty,
|
||||||
clampSceneObjectPosition,
|
clampSceneObjectPosition,
|
||||||
runActionNode,
|
runActionNode,
|
||||||
|
|||||||
+32
-30
@@ -20,6 +20,21 @@
|
|||||||
const PLACEHOLDER_OPEN = '{' + '{';
|
const PLACEHOLDER_OPEN = '{' + '{';
|
||||||
const PLACEHOLDER_CLOSE = '}' + '}';
|
const PLACEHOLDER_CLOSE = '}' + '}';
|
||||||
|
|
||||||
|
// Partagée entre applyOpenRowBindings() et _forgeApplyFreshRuntimeData()
|
||||||
|
// (même algorithme, dupliqué à l'identique dans les deux avant ce
|
||||||
|
// refactor S4144) : `byId` explicite en paramètre plutôt que capturé par
|
||||||
|
// fermeture, puisque chaque appelant construit sa PROPRE table (éléments
|
||||||
|
// de l'écran au moment de son propre appel, jamais partagés entre les
|
||||||
|
// deux).
|
||||||
|
function isDescendantOfElement(byId, e, ancestorId) {
|
||||||
|
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||||
|
while (p) {
|
||||||
|
if (p.id === ancestorId) return true;
|
||||||
|
p = p.parent_id ? byId[p.parent_id] : null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function applyOpenRowBindings() {
|
function applyOpenRowBindings() {
|
||||||
if (!window.currentOpenRow) return;
|
if (!window.currentOpenRow) return;
|
||||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||||
@@ -53,19 +68,11 @@ function applyOpenRowBindings() {
|
|||||||
const byId = {};
|
const byId = {};
|
||||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||||
function hasUnresolvedPlaceholder(e) {
|
function hasUnresolvedPlaceholder(e) {
|
||||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
return !!e.rendered_html?.includes(PLACEHOLDER_OPEN);
|
||||||
}
|
|
||||||
function isDescendantOf(e, ancestorId) {
|
|
||||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
|
||||||
while (p) {
|
|
||||||
if (p.id === ancestorId) return true;
|
|
||||||
p = p.parent_id ? byId[p.parent_id] : null;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||||
const targets = candidates.filter(
|
const targets = candidates.filter(
|
||||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)),
|
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||||
@@ -92,7 +99,7 @@ function applyOpenRowBindings() {
|
|||||||
const toSplit = [];
|
const toSplit = [];
|
||||||
let node;
|
let node;
|
||||||
while ((node = walker.nextNode())) {
|
while ((node = walker.nextNode())) {
|
||||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
if (node.nodeValue.includes(PLACEHOLDER_OPEN)) toSplit.push(node);
|
||||||
}
|
}
|
||||||
toSplit.forEach((textNode) => {
|
toSplit.forEach((textNode) => {
|
||||||
const raw = textNode.nodeValue;
|
const raw = textNode.nodeValue;
|
||||||
@@ -125,9 +132,12 @@ function applyOpenRowBindings() {
|
|||||||
Array.from(node.attributes).forEach((attr) => {
|
Array.from(node.attributes).forEach((attr) => {
|
||||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
||||||
const bindKey = `data-bind-attr-${attr.name}`;
|
const bindKey = `data-bind-attr-${attr.name}`;
|
||||||
const template = node.hasAttribute(bindKey)
|
let template = null;
|
||||||
? node.getAttribute(bindKey)
|
if (node.hasAttribute(bindKey)) {
|
||||||
: (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
template = node.getAttribute(bindKey);
|
||||||
|
} else if (attr.value.includes(PLACEHOLDER_OPEN)) {
|
||||||
|
template = attr.value;
|
||||||
|
}
|
||||||
if (template === null) return;
|
if (template === null) return;
|
||||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||||
placeholderRe.lastIndex = 0;
|
placeholderRe.lastIndex = 0;
|
||||||
@@ -227,19 +237,11 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
const byId = {};
|
const byId = {};
|
||||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||||
const hasMarker = (e) => !!(e.rendered_html && (
|
const hasMarker = (e) => !!(e.rendered_html && (
|
||||||
e.rendered_html.indexOf('repeaterItem') !== -1
|
e.rendered_html.includes('repeaterItem')
|
||||||
|| e.rendered_html.indexOf('jaugeBar') !== -1
|
|| e.rendered_html.includes('jaugeBar')
|
||||||
|| e.rendered_html.indexOf('visibilityGated') !== -1
|
|| e.rendered_html.includes('visibilityGated')
|
||||||
|| e.rendered_html.indexOf('dataBound') !== -1
|
|| e.rendered_html.includes('dataBound')
|
||||||
));
|
));
|
||||||
const isDescendantOf = (e, ancestorId) => {
|
|
||||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
|
||||||
while (p) {
|
|
||||||
if (p.id === ancestorId) return true;
|
|
||||||
p = p.parent_id ? byId[p.parent_id] : null;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||||
// HTML juste après la balise concernée (voir _mark() dans
|
// HTML juste après la balise concernée (voir _mark() dans
|
||||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
// render_element_html.py) — jamais comme une classe CSS, à la
|
||||||
@@ -254,13 +256,13 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||||
// de premier niveau existant (l'exemplaire) que parce que son
|
// de premier niveau existant (l'exemplaire) que parce que son
|
||||||
// HTML est imbriqué dans le sien.
|
// HTML est imbriqué dans le sien.
|
||||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
const COMMENT_MARKERS = new Set(['visibilityGated', 'dataBound']);
|
||||||
const findCommentMarkedDescendants = (root) => {
|
const findCommentMarkedDescendants = (root) => {
|
||||||
const found = [];
|
const found = [];
|
||||||
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
||||||
const sib = node.nextSibling;
|
const sib = node.nextSibling;
|
||||||
const hasCommentMarker = !!(
|
const hasCommentMarker = !!(
|
||||||
sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1
|
sib?.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.has(sib.data)
|
||||||
);
|
);
|
||||||
// Un élément actuellement CACHÉ par une condition de
|
// Un élément actuellement CACHÉ par une condition de
|
||||||
// visibilité (voir render_element_html.py) n'a pas de
|
// visibilité (voir render_element_html.py) n'a pas de
|
||||||
@@ -282,7 +284,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
};
|
};
|
||||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||||
markedCandidates
|
markedCandidates
|
||||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)))
|
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)))
|
||||||
.forEach((el) => {
|
.forEach((el) => {
|
||||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -299,7 +301,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
|||||||
// disparaître ni réapparaître juste parce qu'un texte à
|
// disparaître ni réapparaître juste parce qu'un texte à
|
||||||
// l'intérieur a changé.
|
// l'intérieur a changé.
|
||||||
innerMarked.forEach((innerNode) => {
|
innerMarked.forEach((innerNode) => {
|
||||||
const innerId = innerNode.getAttribute('data-element-id');
|
const innerId = innerNode.dataset.elementId;
|
||||||
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
||||||
if (liveInner) liveInner.replaceWith(innerNode);
|
if (liveInner) liveInner.replaceWith(innerNode);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ function forgeCollisionRuleStateKey(objectId, ruleIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgeElementBoxRect(el, collision) {
|
function forgeElementBoxRect(el, collision) {
|
||||||
const left = parseFloat(el.style.left) || 0;
|
const left = Number.parseFloat(el.style.left) || 0;
|
||||||
const top = parseFloat(el.style.top) || 0;
|
const top = Number.parseFloat(el.style.top) || 0;
|
||||||
const width = parseFloat(el.style.width) || 0;
|
const width = Number.parseFloat(el.style.width) || 0;
|
||||||
const height = parseFloat(el.style.height) || 0;
|
const height = Number.parseFloat(el.style.height) || 0;
|
||||||
return forgeCollisionRectFromBox({
|
return forgeCollisionRectFromBox({
|
||||||
left, top, width, height,
|
left, top, width, height,
|
||||||
}, collision);
|
}, collision);
|
||||||
@@ -86,7 +86,7 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
|||||||
if (!bubble) {
|
if (!bubble) {
|
||||||
bubble = document.createElement('div');
|
bubble = document.createElement('div');
|
||||||
bubble.className = 'collisionInteractBubble';
|
bubble.className = 'collisionInteractBubble';
|
||||||
bubble.setAttribute('data-for-object-id', objectId);
|
bubble.dataset.forObjectId = objectId;
|
||||||
parentEl.appendChild(bubble);
|
parentEl.appendChild(bubble);
|
||||||
}
|
}
|
||||||
return bubble;
|
return bubble;
|
||||||
@@ -112,8 +112,8 @@ function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||||
const parentEl = objectEl && objectEl.parentElement;
|
const parentEl = objectEl?.parentElement;
|
||||||
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
const bubble = parentEl?.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||||
if (bubble) bubble.style.display = 'none';
|
if (bubble) bubble.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
|||||||
if (!bubble) {
|
if (!bubble) {
|
||||||
bubble = document.createElement('div');
|
bubble = document.createElement('div');
|
||||||
bubble.className = 'collisionIndicationBubble';
|
bubble.className = 'collisionIndicationBubble';
|
||||||
bubble.setAttribute('data-for-object-id', objectId);
|
bubble.dataset.forObjectId = objectId;
|
||||||
parentEl.appendChild(bubble);
|
parentEl.appendChild(bubble);
|
||||||
}
|
}
|
||||||
return bubble;
|
return bubble;
|
||||||
@@ -139,7 +139,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
|||||||
|
|
||||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||||
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
||||||
const parentEl = targetEl && targetEl.parentElement;
|
const parentEl = targetEl?.parentElement;
|
||||||
if (!targetEl || !parentEl || !texte) return;
|
if (!targetEl || !parentEl || !texte) return;
|
||||||
const objRect = forgeElementBoxRect(targetEl);
|
const objRect = forgeElementBoxRect(targetEl);
|
||||||
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||||
@@ -153,85 +153,114 @@ function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
|||||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines —
|
||||||
|
// chaque occurrence de "Déclencher ce dialogue" porte le sien, voir
|
||||||
|
// screens/rendering/collision_rules.py) dans le widget "💬 Boîte de
|
||||||
|
// dialogue" posé sur l'écran (voir static/js/play/dialogue-box-
|
||||||
|
// controller.js) — sans effet si aucun widget n'est posé, comme le reste
|
||||||
|
// de ce moteur. Le nom affiché (en-tête du quiz, xAPI) est celui de
|
||||||
|
// l'objet déclencheur (voir "ℹ️ Informations") — plus de "quête" pour le
|
||||||
|
// porter. mark_completed (optionnel) : marque ce dialogue terminé une
|
||||||
|
// fois épuisé. `action.then` (chaînage, voir maquette validée : "pouvoir
|
||||||
|
// modifier un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||||
|
// s'exécuter immédiatement — un dialogue attend des clics du joueur —
|
||||||
|
// transmis à forgeShowDialogueBox, exécuté par dialogue-box-controller.js
|
||||||
|
// une fois le dialogue épuisé.
|
||||||
|
function runDialogueCollisionAction(action, targetEl, obj) {
|
||||||
|
const lines = action.dialogue?.lines || [];
|
||||||
|
const dialogueId = action.dialogue?.id;
|
||||||
|
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||||
|
// "Modifier une variable" du graphe de logique document (voir
|
||||||
|
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||||
|
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||||
|
// TOUJOURS chargée — voir templates/play.html — pas seulement en export,
|
||||||
|
// donc aucun branchement FORGE_OFFLINE nécessaire, même philosophie que
|
||||||
|
// le reste de ce fichier). Instantané : `then` s'exécute tout de suite,
|
||||||
|
// contrairement à un dialogue.
|
||||||
|
function runVariableCollisionAction(action, targetEl, obj) {
|
||||||
|
forgeApplyVariableActionOffline(gameData, action);
|
||||||
|
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réutilise evaluateConditionClause (static/js/play/conditions.js), même
|
||||||
|
// comparaison qu'un nœud Condition de l'éditeur de logique, sur une
|
||||||
|
// VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet ici
|
||||||
|
// (voir maquette validée : le losange ne teste qu'une variable).
|
||||||
|
function runConditionCollisionAction(action, targetEl, obj) {
|
||||||
|
const isTrue = evaluateConditionClause({
|
||||||
|
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||||
|
});
|
||||||
|
const branch = isTrue ? action.si_vrai : action.si_faux;
|
||||||
|
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas forcément
|
||||||
|
// celui qui porte le déclencheur) — réutilise TEL QUEL applyObjectProperty
|
||||||
|
// (static/js/play/actions.js, équivalent scène 2D d'applyElementProperty),
|
||||||
|
// plutôt que de dupliquer cette logique. Partagée entre "surbrillance" et
|
||||||
|
// "visibilite", qui suivent exactement le même patron.
|
||||||
|
function runSurbrillanceOuVisibiliteCollisionAction(action, targetEl, obj) {
|
||||||
|
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
||||||
|
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||||
|
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||||
|
// "Jouer un son" du Flow (static/js/play/actions.js), instantané : `then`
|
||||||
|
// s'exécute tout de suite, sans attendre la fin du son.
|
||||||
|
function runSonCollisionAction(action, targetEl, obj) {
|
||||||
|
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
||||||
|
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||||
|
// "Passer") — voir static/js/play/video-overlay.js, même esprit qu'un
|
||||||
|
// dialogue ci-dessus.
|
||||||
|
function runVideoCollisionAction(action, targetEl, obj) {
|
||||||
|
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||||
|
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runIndicationCollisionAction(action, targetEl, obj) {
|
||||||
|
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||||
|
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend la chaîne "then" pendant data_value SECONDES — même vocabulaire
|
||||||
|
// que l'action "attendre" du Flow (static/js/play/actions.js), utile ex.
|
||||||
|
// pour laisser un temps de lecture après une narration à l'affichage de
|
||||||
|
// l'écran.
|
||||||
|
function runAttendreCollisionAction(action, targetEl, obj) {
|
||||||
|
const seconds = Number.parseFloat(action.data_value);
|
||||||
|
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLISION_RULE_ACTION_HANDLERS = {
|
||||||
|
dialogue: runDialogueCollisionAction,
|
||||||
|
variable: runVariableCollisionAction,
|
||||||
|
condition: runConditionCollisionAction,
|
||||||
|
surbrillance: runSurbrillanceOuVisibiliteCollisionAction,
|
||||||
|
visibilite: runSurbrillanceOuVisibiliteCollisionAction,
|
||||||
|
son: runSonCollisionAction,
|
||||||
|
video: runVideoCollisionAction,
|
||||||
|
indication: runIndicationCollisionAction,
|
||||||
|
attendre: runAttendreCollisionAction,
|
||||||
|
};
|
||||||
|
|
||||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||||
// (affichage de bulle + attente de touche).
|
// (affichage de bulle + attente de touche).
|
||||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||||
if (action.type === 'dialogue') {
|
const handler = COLLISION_RULE_ACTION_HANDLERS[action.type];
|
||||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
if (handler) handler(action, targetEl, obj);
|
||||||
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
|
||||||
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
|
|
||||||
// de dialogue" posé sur l'écran (voir static/js/play/
|
|
||||||
// dialogue-box-controller.js) — sans effet si aucun widget n'est
|
|
||||||
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
|
|
||||||
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
|
||||||
// Informations") — plus de "quête" pour le porter. mark_completed
|
|
||||||
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
|
||||||
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
|
||||||
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
|
||||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
|
||||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
|
||||||
// controller.js une fois le dialogue épuisé.
|
|
||||||
const lines = (action.dialogue && action.dialogue.lines) || [];
|
|
||||||
const dialogueId = action.dialogue && action.dialogue.id;
|
|
||||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
|
||||||
} else if (action.type === 'variable') {
|
|
||||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
|
||||||
// "Modifier une variable" du graphe de logique document (voir
|
|
||||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
|
||||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
|
||||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en
|
|
||||||
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
|
|
||||||
// philosophie que le reste de ce fichier). Instantané : `then`
|
|
||||||
// s'exécute tout de suite, contrairement à un dialogue.
|
|
||||||
forgeApplyVariableActionOffline(gameData, action);
|
|
||||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
|
||||||
} else if (action.type === 'condition') {
|
|
||||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
|
|
||||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
|
||||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
|
||||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
|
||||||
const isTrue = evaluateConditionClause({
|
|
||||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
|
||||||
});
|
|
||||||
const branch = isTrue ? action.si_vrai : action.si_faux;
|
|
||||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
|
||||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
|
||||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
|
||||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
|
||||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
|
||||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
|
||||||
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
|
||||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
|
||||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
|
||||||
} else if (action.type === 'son') {
|
|
||||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
|
||||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
|
||||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
|
||||||
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
|
||||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
|
||||||
} else if (action.type === 'video') {
|
|
||||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
|
||||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
|
||||||
// qu'un dialogue ci-dessus.
|
|
||||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
|
||||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
|
||||||
});
|
|
||||||
} else if (action.type === 'indication') {
|
|
||||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
|
||||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
|
||||||
} else if (action.type === 'attendre') {
|
|
||||||
// Suspend la chaîne "then" pendant data_value SECONDES — même
|
|
||||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
|
||||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
|
||||||
// narration à l'affichage de l'écran.
|
|
||||||
const seconds = parseFloat(action.data_value);
|
|
||||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
|
||||||
}, ms);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeCollisionRuleTick(screenData) {
|
function forgeCollisionRuleTick(screenData) {
|
||||||
@@ -242,7 +271,7 @@ function forgeCollisionRuleTick(screenData) {
|
|||||||
|
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => {
|
||||||
const rules = obj.collision_rules;
|
const rules = obj.collision_rules;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||||
@@ -261,7 +290,7 @@ function forgeCollisionRuleTick(screenData) {
|
|||||||
if (rule.action.type === 'interagir') {
|
if (rule.action.type === 'interagir') {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
anyInteractActive = true;
|
anyInteractActive = true;
|
||||||
forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E');
|
forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, player.personnage_commandes?.interagir || 'E');
|
||||||
}
|
}
|
||||||
forgeCollisionRuleActiveState[stateKey] = isActive;
|
forgeCollisionRuleActiveState[stateKey] = isActive;
|
||||||
return;
|
return;
|
||||||
@@ -280,10 +309,10 @@ function forgeCollisionRuleTick(screenData) {
|
|||||||
function forgeStartCollisionRuleInteractListener(screenData) {
|
function forgeStartCollisionRuleInteractListener(screenData) {
|
||||||
activeCollisionRuleInteractListener = function (ev) {
|
activeCollisionRuleInteractListener = function (ev) {
|
||||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||||
if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
if (!player?.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => {
|
||||||
const rules = obj.collision_rules;
|
const rules = obj.collision_rules;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
rules.forEach((rule, ruleIndex) => {
|
rules.forEach((rule, ruleIndex) => {
|
||||||
@@ -314,7 +343,7 @@ function forgeBindClicSurvolRules(screenData) {
|
|||||||
forgeUnbindClicSurvolRules();
|
forgeUnbindClicSurvolRules();
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => {
|
||||||
const rules = obj.collision_rules;
|
const rules = obj.collision_rules;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
rules.forEach((rule) => {
|
rules.forEach((rule) => {
|
||||||
@@ -347,11 +376,11 @@ function forgeStopCollisionRuleControllers() {
|
|||||||
// ET au moins un objet avec des règles.
|
// ET au moins un objet avec des règles.
|
||||||
function forgeStartCollisionRuleControllers(screenData) {
|
function forgeStartCollisionRuleControllers(screenData) {
|
||||||
forgeStopCollisionRuleControllers();
|
forgeStopCollisionRuleControllers();
|
||||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
if (screenData?.kind !== 'jeu_2d') return;
|
||||||
const elements = screenData.elements || [];
|
const elements = screenData.elements || [];
|
||||||
forgeBindClicSurvolRules(screenData);
|
forgeBindClicSurvolRules(screenData);
|
||||||
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||||
const hasRules = elements.some((o) => o.collision_rules && o.collision_rules.length);
|
const hasRules = elements.some((o) => o.collision_rules?.length);
|
||||||
if (!hasPlayer || !hasRules) return;
|
if (!hasPlayer || !hasRules) return;
|
||||||
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
||||||
forgeStartCollisionRuleInteractListener(screenData);
|
forgeStartCollisionRuleInteractListener(screenData);
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function compareValues(actual, operator, expected, fieldType) {
|
|||||||
const e = normalizeBool(expected);
|
const e = normalizeBool(expected);
|
||||||
return operator === 'different' ? a !== e : a === e;
|
return operator === 'different' ? a !== e : a === e;
|
||||||
}
|
}
|
||||||
const an = parseFloat(actual); const
|
const an = Number.parseFloat(actual); const
|
||||||
en = parseFloat(expected);
|
en = Number.parseFloat(expected);
|
||||||
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
||||||
if (numeric) {
|
if (numeric) {
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
@@ -87,7 +87,7 @@ function compareValues(actual, operator, expected, fieldType) {
|
|||||||
// nécessaire ici : le nom de la variable est choisi dans un menu
|
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||||
// déroulant, voir screen_edit.html).
|
// déroulant, voir screen_edit.html).
|
||||||
function readVariableValue(name) {
|
function readVariableValue(name) {
|
||||||
const v = (gameData.variables || {})[name];
|
const v = gameData.variables?.[name];
|
||||||
return v ? v.value : undefined;
|
return v ? v.value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ function resolveVariablePath(rawValue, path) {
|
|||||||
let m;
|
let m;
|
||||||
while ((m = segmentRe.exec(path)) !== null) {
|
while ((m = segmentRe.exec(path)) !== null) {
|
||||||
if (current === null || current === undefined) return null;
|
if (current === null || current === undefined) return null;
|
||||||
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
current = m[1] !== undefined ? current[m[1]] : current[Number.parseInt(m[2], 10)];
|
||||||
}
|
}
|
||||||
return current === undefined ? null : current;
|
return current === undefined ? null : current;
|
||||||
}
|
}
|
||||||
@@ -129,13 +129,13 @@ function resolveVariablePath(rawValue, path) {
|
|||||||
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
|
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
|
||||||
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
||||||
function forgeCollisionRectFromBox(box, collision) {
|
function forgeCollisionRectFromBox(box, collision) {
|
||||||
const width = (collision && collision.width) || box.width;
|
const width = collision?.width || box.width;
|
||||||
const height = (collision && collision.height) || box.height;
|
const height = collision?.height || box.height;
|
||||||
const offsetX = (collision && collision.offset_x) || 0;
|
const offsetX = collision?.offset_x || 0;
|
||||||
const offsetY = (collision && collision.offset_y) || 0;
|
const offsetY = collision?.offset_y || 0;
|
||||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
const top = box.top + (box.height - height) / 2 + offsetY;
|
||||||
const shape = (collision && collision.shape) || 'rectangle';
|
const shape = collision?.shape || 'rectangle';
|
||||||
return {
|
return {
|
||||||
left, top, right: left + width, bottom: top + height, width, height, shape,
|
left, top, right: left + width, bottom: top + height, width, height, shape,
|
||||||
};
|
};
|
||||||
@@ -156,7 +156,7 @@ function forgeCirclesOverlap(a, b) {
|
|||||||
br = Math.min(b.width, b.height) / 2;
|
br = Math.min(b.width, b.height) / 2;
|
||||||
const dx = acx - bcx; const
|
const dx = acx - bcx; const
|
||||||
dy = acy - bcy;
|
dy = acy - bcy;
|
||||||
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
|
return Math.hypot(dx, dy) < (ar + br);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX
|
// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX
|
||||||
@@ -190,13 +190,13 @@ function elementsOverlap(elementIdA, elementIdB) {
|
|||||||
const objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
const objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
||||||
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
||||||
if (
|
if (
|
||||||
(objA && objA.collision && objA.collision.enabled === false)
|
objA?.collision?.enabled === false
|
||||||
|| (objB && objB.collision && objB.collision.enabled === false)
|
|| objB?.collision?.enabled === false
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA?.collision);
|
||||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
|
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB?.collision);
|
||||||
return forgeShapesOverlap(rectA, rectB);
|
return forgeShapesOverlap(rectA, rectB);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +215,9 @@ function evaluateConditionClause(clause) {
|
|||||||
if (source === 'variable') {
|
if (source === 'variable') {
|
||||||
const varName = clause.variable ?? clause.cond_variable;
|
const varName = clause.variable ?? clause.cond_variable;
|
||||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||||
const varInfo = (gameData.variables || {})[varName];
|
const varInfo = gameData.variables?.[varName];
|
||||||
const actual = resolveVariablePath(readVariableValue(varName), path);
|
const actual = resolveVariablePath(readVariableValue(varName), path);
|
||||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
const fieldType = varInfo?.type === 'booleen' ? 'booleen' : '';
|
||||||
return compareValues(actual, operator, expected, fieldType);
|
return compareValues(actual, operator, expected, fieldType);
|
||||||
}
|
}
|
||||||
const actual = readFieldValue(
|
const actual = readFieldValue(
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
|||||||
|
|
||||||
function forgeEscapeHtml(str) {
|
function forgeEscapeHtml(str) {
|
||||||
return String(str == null ? '' : str)
|
return String(str == null ? '' : str)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||||
.replace(/"/g, '"');
|
.replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Boîte de dialogue ----------
|
// ---------- Boîte de dialogue ----------
|
||||||
@@ -141,7 +141,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
|||||||
function forgeEscapeCameraForFullscreen(widget) {
|
function forgeEscapeCameraForFullscreen(widget) {
|
||||||
if (widget.parentNode === document.body) return;
|
if (widget.parentNode === document.body) return;
|
||||||
const screenEl = widget.closest('.playScreen');
|
const screenEl = widget.closest('.playScreen');
|
||||||
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
|
if (screenEl) widget.dataset.ownerScreenId = screenEl.dataset.screenId;
|
||||||
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||||
document.body.appendChild(widget);
|
document.body.appendChild(widget);
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
|||||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
widget.classList.remove('is-active', 'is-fullscreen');
|
widget.classList.remove('is-active', 'is-fullscreen');
|
||||||
const objectId = widget.getAttribute('data-object-id');
|
const objectId = widget.dataset.objectId;
|
||||||
forgeStopQuizTimer(objectId);
|
forgeStopQuizTimer(objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ const forgeQuizTimerState = {};
|
|||||||
|
|
||||||
function forgeStopQuizTimer(objectId) {
|
function forgeStopQuizTimer(objectId) {
|
||||||
const t = forgeQuizTimerState[objectId];
|
const t = forgeQuizTimerState[objectId];
|
||||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
if (t?.intervalId) clearInterval(t.intervalId);
|
||||||
delete forgeQuizTimerState[objectId];
|
delete forgeQuizTimerState[objectId];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
|||||||
|
|
||||||
function forgeUpdateAllScoreWidgets() {
|
function forgeUpdateAllScoreWidgets() {
|
||||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||||
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
for (const value of values) value.textContent = window.forgeQuizScore;
|
||||||
forgeUpdateQuizBoxScoreDisplays();
|
forgeUpdateQuizBoxScoreDisplays();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ function forgeUpdateAllScoreWidgets() {
|
|||||||
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
||||||
function forgeUpdateQuizBoxScoreDisplays() {
|
function forgeUpdateQuizBoxScoreDisplays() {
|
||||||
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||||
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
|
for (const value of values) value.textContent = `Score : ${window.forgeQuizScore}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||||
@@ -341,7 +341,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
|||||||
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
||||||
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
||||||
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||||
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
|
const stateKey = widget ? widget.dataset.objectId : (`_sansBoiteDialogue_${dialogueId}`);
|
||||||
forgeSyncDialogueStartedToScorm();
|
forgeSyncDialogueStartedToScorm();
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||||
@@ -400,7 +400,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
|||||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||||
function forgeDialogueBoxAdvance(objectId) {
|
function forgeDialogueBoxAdvance(objectId) {
|
||||||
const state = forgeDialogueBoxState[objectId];
|
const state = forgeDialogueBoxState[objectId];
|
||||||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
if (!state || state.lines[state.index]?.type === 'question') return;
|
||||||
forgeAdvanceConversation(objectId);
|
forgeAdvanceConversation(objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ function forgeDialogueBoxAdvance(objectId) {
|
|||||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
const previousState = forgeQuizBoxState[quizObjectId];
|
const previousState = forgeQuizBoxState[quizObjectId];
|
||||||
forgeQuizBoxState[quizObjectId] = {
|
forgeQuizBoxState[quizObjectId] = {
|
||||||
dialogueObjectId,
|
dialogueObjectId,
|
||||||
@@ -456,14 +456,14 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
|||||||
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
||||||
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
||||||
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
||||||
const isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
const isFullscreen = widget.dataset.fullscreen === '1';
|
||||||
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
||||||
widget.classList.toggle('is-fullscreen', isFullscreen);
|
widget.classList.toggle('is-fullscreen', isFullscreen);
|
||||||
forgeUpdateQuizBoxScoreDisplays();
|
forgeUpdateQuizBoxScoreDisplays();
|
||||||
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
||||||
// imposé, voir screens/rendering/quiz_box_config.py).
|
// imposé, voir screens/rendering/quiz_box_config.py).
|
||||||
const timerMode = widget.getAttribute('data-timer-mode');
|
const timerMode = widget.dataset.timerMode;
|
||||||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
const timerSeconds = Number.parseInt(widget.dataset.timerSeconds, 10) || 0;
|
||||||
if (timerMode === 'question') {
|
if (timerMode === 'question') {
|
||||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||||
@@ -477,7 +477,17 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
|||||||
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
|
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
|
||||||
// son animation CSS.
|
// son animation CSS.
|
||||||
widget.classList.remove('is-active');
|
widget.classList.remove('is-active');
|
||||||
void widget.offsetWidth;
|
// Lecture volontairement sans effet apparent : force le navigateur à
|
||||||
|
// calculer la mise en page AVANT le .add() ci-dessous (sans ça, les deux
|
||||||
|
// changements de classe sont fusionnés en une seule passe de rendu et
|
||||||
|
// l'animation ne redémarre jamais). `void` est la forme la plus claire
|
||||||
|
// pour marquer une expression délibérément ignorée (accepté par ESLint,
|
||||||
|
// no-unused-expressions désactivé dans ce projet) — Sonar n'a aucun
|
||||||
|
// idiome qu'il reconnaisse pour ce cas : S3735 (void), S905 (expression
|
||||||
|
// seule sans void) et S1481 (variable jamais lue) se sont succédé sur
|
||||||
|
// les 3 formes essayées, chacune corrigeant la précédente en en créant
|
||||||
|
// une autre. NOSONAR ici plutôt que de continuer, voir CODE_QUALITY.md.
|
||||||
|
void widget.offsetWidth; // NOSONAR S3735 - reflow force, aucune forme alternative ne satisfait Sonar, CODE_QUALITY.md
|
||||||
widget.classList.add('is-active');
|
widget.classList.add('is-active');
|
||||||
// Effet d'entrée propre au modèle (voir static/js/play/
|
// Effet d'entrée propre au modèle (voir static/js/play/
|
||||||
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
||||||
@@ -489,42 +499,27 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
|||||||
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
||||||
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
||||||
function forgeQuizTemplateEffectsFor(widget) {
|
function forgeQuizTemplateEffectsFor(widget) {
|
||||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
const template = widget?.dataset.quizTemplate || 'defaut';
|
||||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
|
// Surligne la bonne réponse en vert (et le choix cliqué en rouge s'il
|
||||||
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
|
// était faux) sur les boutons injectés par forgeShowQuizBox — extrait de
|
||||||
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
|
// forgeQuizBoxAnswer (lot 4 "modernisation JS", S3776).
|
||||||
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
|
function markQuizChoiceButtons(widget, state, choiceIndex) {
|
||||||
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
|
if (!widget) return;
|
||||||
// explicite : une mauvaise réponse ne doit jamais bloquer la
|
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||||
// progression) — surligne la bonne réponse en vert (et le choix cliqué
|
for (const [i, button] of buttons.entries()) {
|
||||||
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
|
button.disabled = true;
|
||||||
// second clic pendant cet affichage est ignoré (`resolved`).
|
if (i === state.correctIndex) button.classList.add('is-correct');
|
||||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
else if (i === choiceIndex) button.classList.add('is-wrong');
|
||||||
const state = forgeQuizBoxState[quizObjectId];
|
|
||||||
if (!state || state.resolved) return;
|
|
||||||
state.resolved = true;
|
|
||||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
|
||||||
const correct = choiceIndex === state.correctIndex;
|
|
||||||
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
|
||||||
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
|
||||||
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
|
||||||
// "pour tout le quiz", qui doit continuer à tourner sans interruption
|
|
||||||
// d'une question à l'autre.
|
|
||||||
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
|
|
||||||
if (widget) {
|
|
||||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
|
||||||
for (let i = 0; i < buttons.length; i++) {
|
|
||||||
buttons[i].disabled = true;
|
|
||||||
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
|
|
||||||
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.forgeQuizTotalAnswered += 1;
|
}
|
||||||
state.answeredCount += 1;
|
|
||||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
// Crédite le score/compteurs SI la réponse est bonne, puis déclenche
|
||||||
|
// l'effet visuel du modèle actif (onCorrect/onWrong, voir static/js/play/
|
||||||
|
// quiz-box-templates.js) — extrait de forgeQuizBoxAnswer.
|
||||||
|
function applyQuizAnswerScoreAndEffect(widget, state, correct, fx) {
|
||||||
if (correct) {
|
if (correct) {
|
||||||
window.forgeQuizCorrectAnswered += 1;
|
window.forgeQuizCorrectAnswered += 1;
|
||||||
state.correctCount += 1;
|
state.correctCount += 1;
|
||||||
@@ -533,6 +528,11 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
} else if (fx.onWrong) {
|
} else if (fx.onWrong) {
|
||||||
fx.onWrong(widget);
|
fx.onWrong(widget);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// xAPI + SCORM 2004 (cmi.interactions.n.*, no-op en SCORM 1.2/hors LMS) —
|
||||||
|
// extrait de forgeQuizBoxAnswer.
|
||||||
|
function notifyQuizAnswerTelemetry(state, correct, choiceIndex) {
|
||||||
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||||
forgeXapiNotifyQuestionAnswered(
|
forgeXapiNotifyQuestionAnswered(
|
||||||
state.dialogueId,
|
state.dialogueId,
|
||||||
@@ -543,8 +543,6 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
correct ? state.rewardAmount : 0,
|
correct ? state.rewardAmount : 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
|
||||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
|
||||||
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
||||||
forgeScorm2004NotifyQuestionAnswered(
|
forgeScorm2004NotifyQuestionAnswered(
|
||||||
state.dialogueId,
|
state.dialogueId,
|
||||||
@@ -556,19 +554,50 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
|||||||
state.correctIndex,
|
state.correctIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
}
|
||||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
|
||||||
// lent ou un utilisateur de lecteur d'écran — l'avancée dépend
|
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul après
|
||||||
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
|
// un délai fixe (700ms/1400ms), jamais assez pour un lecteur lent ou un
|
||||||
// minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox
|
// utilisateur de lecteur d'écran — l'avancée dépend maintenant
|
||||||
// ci-dessus, TOUJOURS visible — demande explicite, comme la maquette
|
// uniquement d'un clic sur "Continuer →", jamais d'un minuteur. Le
|
||||||
// manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça
|
// bouton existe déjà (posé désactivé par forgeShowQuizBox, TOUJOURS
|
||||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
// visible — demande explicite, comme la maquette manga) : on se contente
|
||||||
// choix juste au-dessus).
|
// ici de l'ACTIVER, jamais de le recréer (ça effacerait le surlignage
|
||||||
if (widget) {
|
// vert/rouge qu'on vient de poser sur les choix). Extrait de
|
||||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
// forgeQuizBoxAnswer.
|
||||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
function enableQuizContinueButton(widget) {
|
||||||
}
|
if (!widget) return;
|
||||||
|
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||||
|
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
|
||||||
|
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
|
||||||
|
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
|
||||||
|
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
|
||||||
|
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
|
||||||
|
// explicite : une mauvaise réponse ne doit jamais bloquer la
|
||||||
|
// progression). Un second clic pendant cet affichage est ignoré
|
||||||
|
// (`resolved`).
|
||||||
|
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||||
|
const state = forgeQuizBoxState[quizObjectId];
|
||||||
|
if (!state || state.resolved) return;
|
||||||
|
state.resolved = true;
|
||||||
|
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||||
|
const correct = choiceIndex === state.correctIndex;
|
||||||
|
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
||||||
|
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
||||||
|
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
||||||
|
// "pour tout le quiz", qui doit continuer à tourner sans interruption
|
||||||
|
// d'une question à l'autre.
|
||||||
|
if (widget?.dataset.timerMode === 'question') forgeStopQuizTimer(quizObjectId);
|
||||||
|
markQuizChoiceButtons(widget, state, choiceIndex);
|
||||||
|
window.forgeQuizTotalAnswered += 1;
|
||||||
|
state.answeredCount += 1;
|
||||||
|
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||||
|
applyQuizAnswerScoreAndEffect(widget, state, correct, fx);
|
||||||
|
notifyQuizAnswerTelemetry(state, correct, choiceIndex);
|
||||||
|
enableQuizContinueButton(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
|
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
|
||||||
@@ -595,7 +624,7 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
|||||||
const { answeredCount } = state;
|
const { answeredCount } = state;
|
||||||
return function () {
|
return function () {
|
||||||
widget.classList.add('is-active');
|
widget.classList.add('is-active');
|
||||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
if (widget.dataset.fullscreen === '1') widget.classList.add('is-fullscreen');
|
||||||
completeFx(widget, { correctCount, totalCount: answeredCount });
|
completeFx(widget, { correctCount, totalCount: answeredCount });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,3 +111,25 @@ test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', (
|
|||||||
assert.equal(decoded.clauses.length, 1);
|
assert.equal(decoded.clauses.length, 1);
|
||||||
assert.equal(decoded.combinator, 'ou');
|
assert.equal(decoded.combinator, 'ou');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forgeDecodeClauses — _filtres_json invalide : avertit en console, retombe sur le legacy sans planter (bug S2486 : exception avalée en silence auparavant)', () => {
|
||||||
|
const attrs = {
|
||||||
|
_filtres_json: '{not valid json',
|
||||||
|
_filtre_champ: 'pv',
|
||||||
|
_filtre_operateur: 'superieur_egal',
|
||||||
|
_filtre_valeur: '10',
|
||||||
|
};
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const warnCalls = [];
|
||||||
|
console.warn = (...args) => { warnCalls.push(args); };
|
||||||
|
try {
|
||||||
|
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
|
||||||
|
assert.equal(warnCalls.length, 1);
|
||||||
|
assert.match(warnCalls[0][0], /_filtres_json invalide/);
|
||||||
|
// Repli sur l'ancien format à 2 filtres fixes, comme avant ce fix.
|
||||||
|
assert.equal(decoded.clauses.length, 1);
|
||||||
|
assert.equal(decoded.clauses[0].champ, 'pv');
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -136,6 +136,121 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
|
|||||||
assert.ok(html.includes('jaugeBar'));
|
assert.ok(html.includes('jaugeBar'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Couverture des branches d'afficherValeur/fieldNameNom AVANT le refactor
|
||||||
|
// S3776 (24 -> lot 4 "modernisation JS") — pour garantir que l'extraction
|
||||||
|
// en sous-fonctions ne change aucun comportement.
|
||||||
|
test('jauge — _afficher_valeur affiche la valeur brute du champ dans un label superposé', () => {
|
||||||
|
const el = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _afficher_valeur: '1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||||
|
assert.ok(html.includes('>42</div>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jauge — _afficher_valeur sans valeur trouvée (champ inconnu) affiche un tiret cadratin, pas "null"/"undefined"', () => {
|
||||||
|
const el = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1', _champ: 'champ_qui_n_existe_pas', _min: '0', _max: '100', _afficher_valeur: '1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||||
|
assert.ok(html.includes('>—</div>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jauge — _row_id sélectionne une ligne précise (pas la plus récente)', () => {
|
||||||
|
const el = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1', _champ: 'points', _row_id: '2', _min: '0', _max: '100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||||
|
assert.ok(html.includes('value="7"')); // ligne id=2 (Bob, points=7), pas la plus récente (Alice)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jauge — _champ_nom affiche un label avec le nom, aligné à gauche seulement en position "dessus"+"gauche"', () => {
|
||||||
|
const elGauche = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1',
|
||||||
|
_champ: 'points',
|
||||||
|
_champ_nom: 'nom',
|
||||||
|
_min: '0',
|
||||||
|
_max: '100',
|
||||||
|
_nom_position: 'dessus',
|
||||||
|
_nom_alignement: 'gauche',
|
||||||
|
_nom_gras: '1',
|
||||||
|
_nom_italique: '1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const htmlGauche = forgeRenderElementHtml(elGauche, baseGameData(), {}, {});
|
||||||
|
assert.ok(htmlGauche.includes('text-align:left'));
|
||||||
|
assert.ok(htmlGauche.includes('font-weight:700'));
|
||||||
|
assert.ok(htmlGauche.includes('font-style:italic'));
|
||||||
|
assert.ok(htmlGauche.includes('>Alice</div>'));
|
||||||
|
|
||||||
|
const elCote = {
|
||||||
|
id: 11,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1', _champ: 'points', _champ_nom: 'nom', _min: '0', _max: '100', _nom_position: 'cote',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const htmlCote = forgeRenderElementHtml(elCote, baseGameData(), {}, {});
|
||||||
|
assert.ok(htmlCote.includes('text-align:center'));
|
||||||
|
assert.ok(htmlCote.includes('flex-direction:row')); // "cote" bascule aussi la disposition générale
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jauge — _champ_nom sans valeur trouvée (ligne/champ vide) : aucun label de nom généré', () => {
|
||||||
|
const el = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1', _champ: 'points', _champ_nom: 'champ_qui_n_existe_pas', _min: '0', _max: '100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||||
|
assert.ok(!html.includes('text-align'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jauge — classe CSS supplémentaire (_class_*) fusionnée sur la barre', () => {
|
||||||
|
const el = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: {
|
||||||
|
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _class_extra: 'ma-classe',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||||
|
assert.match(html, /class="progress jaugeBar ma-classe"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jauge — min/max absents retombent sur 0/100, valeur non numérique retombe sur min', () => {
|
||||||
|
const el = {
|
||||||
|
id: 10,
|
||||||
|
widget: 'jauge',
|
||||||
|
style: {},
|
||||||
|
attributes: { _definition_id: '1', _champ: 'nom' }, // "nom" = texte, pas un nombre
|
||||||
|
};
|
||||||
|
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||||
|
assert.ok(html.includes('value="0"')); // valeur non numérique -> repli sur min (0 par défaut)
|
||||||
|
});
|
||||||
|
|
||||||
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||||
const el = {
|
const el = {
|
||||||
id: 11,
|
id: 11,
|
||||||
|
|||||||
@@ -18,53 +18,82 @@ function forgePyMod(a, b) {
|
|||||||
return ((a % b) + b) % b;
|
return ((a % b) + b) % b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeDefinirNombre(rawValue, round) {
|
||||||
|
const n = Number.parseFloat(rawValue);
|
||||||
|
return Number.isNaN(n) ? 0 : round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partagée entre "incrementer" et "decrementer" (même calcul, signe
|
||||||
|
// inversé) — extrait de forgeComputeNewValue (lot 4 "modernisation JS", S3776).
|
||||||
|
function computeIncrementerOuDecrementer(operation, current, rawValue, round) {
|
||||||
|
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
|
||||||
|
if (Number.isNaN(amount)) amount = 0;
|
||||||
|
if (operation === 'decrementer') amount = -amount;
|
||||||
|
return round((current || 0) + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMultiplier(current, rawValue, round) {
|
||||||
|
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : Number.parseFloat(rawValue);
|
||||||
|
if (Number.isNaN(factor)) factor = 1;
|
||||||
|
return round((current || 0) * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partagée entre "diviser" et "modulo" — extrait de forgeComputeNewValue.
|
||||||
|
function computeDiviserOuModulo(operation, current, rawValue, round) {
|
||||||
|
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
|
||||||
|
if (Number.isNaN(divisor)) divisor = 0;
|
||||||
|
if (divisor === 0) return current;
|
||||||
|
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||||
|
return round(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partagée entre "minimum" et "maximum" — extrait de forgeComputeNewValue.
|
||||||
|
function computeMinimumOuMaximum(operation, current, rawValue, round) {
|
||||||
|
const bound = Number.parseFloat(rawValue);
|
||||||
|
if (Number.isNaN(bound)) return current;
|
||||||
|
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||||
|
return round(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAlea(rawValue, isDecimal) {
|
||||||
|
const parts = String(rawValue || '').split(',');
|
||||||
|
let lo = Number.parseFloat(parts[0]); let
|
||||||
|
hi = Number.parseFloat(parts[1]);
|
||||||
|
if (Number.isNaN(lo)) lo = 0;
|
||||||
|
if (Number.isNaN(hi)) hi = 0;
|
||||||
|
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||||
|
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||||
|
const iLo = Math.trunc(lo); const
|
||||||
|
iHi = Math.trunc(hi);
|
||||||
|
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch par nom d'opération — chaque fonction ci-dessus reçoit déjà
|
||||||
|
// tout ce dont elle a besoin (current/rawValue/round/isDecimal), ce
|
||||||
|
// dispatcher n'a donc plus qu'une seule décision (présence d'un
|
||||||
|
// handler), au lieu d'empiler ici les conditions de chaque branche (même
|
||||||
|
// méthode que runActionNode, static/js/play/actions.js).
|
||||||
|
const OPERATION_HANDLERS = {
|
||||||
|
definir_texte: (current, rawValue) => rawValue || '',
|
||||||
|
definir_nombre: (current, rawValue, round) => computeDefinirNombre(rawValue, round),
|
||||||
|
definir_bool_vrai: () => 1,
|
||||||
|
definir_bool_faux: () => 0,
|
||||||
|
basculer_bool: (current) => (current ? 0 : 1),
|
||||||
|
incrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('incrementer', current, rawValue, round),
|
||||||
|
decrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('decrementer', current, rawValue, round),
|
||||||
|
multiplier: (current, rawValue, round) => computeMultiplier(current, rawValue, round),
|
||||||
|
diviser: (current, rawValue, round) => computeDiviserOuModulo('diviser', current, rawValue, round),
|
||||||
|
modulo: (current, rawValue, round) => computeDiviserOuModulo('modulo', current, rawValue, round),
|
||||||
|
minimum: (current, rawValue, round) => computeMinimumOuMaximum('minimum', current, rawValue, round),
|
||||||
|
maximum: (current, rawValue, round) => computeMinimumOuMaximum('maximum', current, rawValue, round),
|
||||||
|
alea: (current, rawValue, round, isDecimal) => computeAlea(rawValue, isDecimal),
|
||||||
|
};
|
||||||
|
|
||||||
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||||
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||||
if (operation === 'definir_texte') return rawValue || '';
|
const handler = OPERATION_HANDLERS[operation];
|
||||||
if (operation === 'definir_nombre') {
|
if (!handler) throw new Error(`opération inconnue : ${operation}`);
|
||||||
const n = parseFloat(rawValue);
|
return handler(current, rawValue, round, isDecimal);
|
||||||
return Number.isNaN(n) ? 0 : round(n);
|
|
||||||
}
|
|
||||||
if (operation === 'definir_bool_vrai') return 1;
|
|
||||||
if (operation === 'definir_bool_faux') return 0;
|
|
||||||
if (operation === 'basculer_bool') return current ? 0 : 1;
|
|
||||||
if (operation === 'incrementer' || operation === 'decrementer') {
|
|
||||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
|
||||||
if (Number.isNaN(amount)) amount = 0;
|
|
||||||
if (operation === 'decrementer') amount = -amount;
|
|
||||||
return round((current || 0) + amount);
|
|
||||||
}
|
|
||||||
if (operation === 'multiplier') {
|
|
||||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : parseFloat(rawValue);
|
|
||||||
if (Number.isNaN(factor)) factor = 1;
|
|
||||||
return round((current || 0) * factor);
|
|
||||||
}
|
|
||||||
if (operation === 'diviser' || operation === 'modulo') {
|
|
||||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
|
||||||
if (Number.isNaN(divisor)) divisor = 0;
|
|
||||||
if (divisor === 0) return current;
|
|
||||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
|
||||||
return round(result);
|
|
||||||
}
|
|
||||||
if (operation === 'minimum' || operation === 'maximum') {
|
|
||||||
const bound = parseFloat(rawValue);
|
|
||||||
if (Number.isNaN(bound)) return current;
|
|
||||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
|
||||||
return round(res);
|
|
||||||
}
|
|
||||||
if (operation === 'alea') {
|
|
||||||
const parts = String(rawValue || '').split(',');
|
|
||||||
let lo = parseFloat(parts[0]); let
|
|
||||||
hi = parseFloat(parts[1]);
|
|
||||||
if (Number.isNaN(lo)) lo = 0;
|
|
||||||
if (Number.isNaN(hi)) hi = 0;
|
|
||||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
|
||||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
|
||||||
const iLo = Math.trunc(lo); const
|
|
||||||
iHi = Math.trunc(hi);
|
|
||||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
|
||||||
}
|
|
||||||
throw new Error(`opération inconnue : ${operation}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// _compare() côté Python (chargé avant ce fichier dans templates/
|
// _compare() côté Python (chargé avant ce fichier dans templates/
|
||||||
// play.html), pas de troisième copie de cette logique.
|
// play.html), pas de troisième copie de cette logique.
|
||||||
|
|
||||||
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; // NOSONAR S8786 - classes negatives, decoupage sans ambiguite, teste jusqu'a 100k car. sans blowup (voir CODE_QUALITY.md)
|
||||||
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md)
|
||||||
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||||
|
|
||||||
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
|
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
|
||||||
@@ -21,7 +21,15 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (Array.isArray(parsed)) clauses = parsed;
|
if (Array.isArray(parsed)) clauses = parsed;
|
||||||
} catch (e) { clauses = null; }
|
} catch (e) {
|
||||||
|
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||||
|
// _filtres_json est produit par le rendu serveur, jamais tapé à la
|
||||||
|
// main — un JSON invalide ici trahit presque toujours un bug côté
|
||||||
|
// éditeur/serveur. Comportement inchangé : repli sur l'ancien
|
||||||
|
// format ou aucun filtre, comme avant.
|
||||||
|
console.warn('_filtres_json invalide, filtre ignoré', e);
|
||||||
|
clauses = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (clauses === null && legacyPrefix) {
|
if (clauses === null && legacyPrefix) {
|
||||||
clauses = [];
|
clauses = [];
|
||||||
@@ -48,7 +56,7 @@ function forgeResolveVariablePath(rawValue, path) {
|
|||||||
let current;
|
let current;
|
||||||
try {
|
try {
|
||||||
current = rawValue ? JSON.parse(rawValue) : null;
|
current = rawValue ? JSON.parse(rawValue) : null;
|
||||||
} catch (e) {
|
} catch (e) { // NOSONAR S2486 - variable pas au format JSON, cas normal (voir commentaire de la fonction)
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
let m;
|
let m;
|
||||||
@@ -57,9 +65,9 @@ function forgeResolveVariablePath(rawValue, path) {
|
|||||||
const key = m[1]; const
|
const key = m[1]; const
|
||||||
index = m[2];
|
index = m[2];
|
||||||
try {
|
try {
|
||||||
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
|
current = key !== undefined ? current[key] : current[Number.parseInt(index, 10)];
|
||||||
if (current === undefined) return null;
|
if (current === undefined) return null;
|
||||||
} catch (e) {
|
} catch (e) { // NOSONAR S2486 - chemin qui ne correspond pas a la forme actuelle de la variable, cas normal
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +99,7 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
|||||||
|
|
||||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||||
if (varMatch) {
|
if (varMatch) {
|
||||||
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
const variable = gameData.variables?.[varMatch[1].trim()];
|
||||||
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
||||||
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
||||||
}
|
}
|
||||||
@@ -103,8 +111,8 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
|||||||
const names = gameData.definition_names || {};
|
const names = gameData.definition_names || {};
|
||||||
for (const defId in names) {
|
for (const defId in names) {
|
||||||
if (names[defId] === objectName) {
|
if (names[defId] === objectName) {
|
||||||
const rows = gameData.data && gameData.data[defId];
|
const rows = gameData.data?.[defId];
|
||||||
if (rows && rows.length) {
|
if (rows?.length) {
|
||||||
return rows[0][fieldName];
|
return rows[0][fieldName];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -119,7 +127,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
|||||||
if (!fieldName) return true;
|
if (!fieldName) return true;
|
||||||
const operator = clause.operateur || 'egal';
|
const operator = clause.operateur || 'egal';
|
||||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
||||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||||
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
||||||
const fieldType = field ? field.type : 'texte';
|
const fieldType = field ? field.type : 'texte';
|
||||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||||
@@ -127,7 +135,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
|||||||
|
|
||||||
// Port de filter_rows_by_clauses.
|
// Port de filter_rows_by_clauses.
|
||||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||||
if (!clauses || !clauses.length) return rows;
|
if (!clauses?.length) return rows;
|
||||||
if (combinator === 'ou') {
|
if (combinator === 'ou') {
|
||||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||||
// via forgeSpecialRenderer().
|
// via forgeSpecialRenderer().
|
||||||
|
|
||||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
|
const FORGE_LAYOUT_CAPABLE_WIDGETS = new Set(['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau']);
|
||||||
|
|
||||||
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
||||||
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
||||||
@@ -66,9 +66,9 @@ const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
|
|||||||
|
|
||||||
function forgeHtmlEscape(s) {
|
function forgeHtmlEscape(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||||
.replace(/"/g, '"')
|
.replaceAll('"', '"')
|
||||||
.replace(/'/g, ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
||||||
@@ -89,7 +89,7 @@ function forgeAutoId(el) {
|
|||||||
|
|
||||||
// Port de parent_flex_direction.py.
|
// Port de parent_flex_direction.py.
|
||||||
function forgeParentFlexDirection(parentEl) {
|
function forgeParentFlexDirection(parentEl) {
|
||||||
const style = (parentEl && parentEl.style) || {};
|
const style = parentEl?.style || {};
|
||||||
if (style.display !== 'flex') return null;
|
if (style.display !== 'flex') return null;
|
||||||
return style['flex-direction'] || 'row';
|
return style['flex-direction'] || 'row';
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,8 @@ function forgeParentFlexDirection(parentEl) {
|
|||||||
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
|
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
|
||||||
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
||||||
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||||
const style = { ...el.style || {} };
|
const style = { ...el.style };
|
||||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
if (FORGE_LAYOUT_CAPABLE_WIDGETS.has(widgetKey) && !('display' in style)) {
|
||||||
style.display = 'flex';
|
style.display = 'flex';
|
||||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||||
}
|
}
|
||||||
@@ -150,13 +150,13 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
|||||||
const rawAttrs = el.attributes || {};
|
const rawAttrs = el.attributes || {};
|
||||||
const attrs = {};
|
const attrs = {};
|
||||||
Object.keys(rawAttrs).forEach((k) => {
|
Object.keys(rawAttrs).forEach((k) => {
|
||||||
if (k.indexOf('_') === 0) return;
|
if (k.startsWith('_')) return;
|
||||||
const v = rawAttrs[k];
|
const v = rawAttrs[k];
|
||||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||||
});
|
});
|
||||||
Object.assign(attrs, meta.fixedAttrs || {});
|
Object.assign(attrs, meta.fixedAttrs || {});
|
||||||
const extraClasses = Object.keys(rawAttrs)
|
const extraClasses = Object.keys(rawAttrs)
|
||||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
.filter((k) => k.startsWith('_class_') && rawAttrs[k])
|
||||||
.map((k) => rawAttrs[k]);
|
.map((k) => rawAttrs[k]);
|
||||||
if (extraClasses.length) {
|
if (extraClasses.length) {
|
||||||
const baseClass = attrs.class || '';
|
const baseClass = attrs.class || '';
|
||||||
@@ -170,7 +170,7 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
|||||||
|
|
||||||
// Port de render_children.py.
|
// Port de render_children.py.
|
||||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
const kids = childrenMap?.[el.id] || [];
|
||||||
const flexDir = forgeParentFlexDirection(el);
|
const flexDir = forgeParentFlexDirection(el);
|
||||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
||||||
}
|
}
|
||||||
@@ -182,9 +182,9 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
|||||||
let definitionId = attrs._data_definition_id;
|
let definitionId = attrs._data_definition_id;
|
||||||
if (!definitionId) return null;
|
if (!definitionId) return null;
|
||||||
definitionId = String(definitionId);
|
definitionId = String(definitionId);
|
||||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||||
if (!fieldsMetaForDef) return null;
|
if (!fieldsMetaForDef) return null;
|
||||||
let rows = (gameData.data && gameData.data[definitionId]) || [];
|
let rows = gameData.data?.[definitionId] || [];
|
||||||
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
||||||
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||||
if (!rows.length) return null;
|
if (!rows.length) return null;
|
||||||
@@ -206,8 +206,8 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
|||||||
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
||||||
// d'éventuels enfants copiés en base.
|
// d'éventuels enfants copiés en base.
|
||||||
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
const templateTree = gameData.element_type_templates?.[String(el.element_type_id)];
|
||||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
if (!templateTree?.top.length) return [null, false];
|
||||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||||
let { top } = templateTree;
|
let { top } = templateTree;
|
||||||
if (isOverlayOnly) {
|
if (isOverlayOnly) {
|
||||||
@@ -255,7 +255,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
const attrsRaw = el.attributes || {};
|
const attrsRaw = el.attributes || {};
|
||||||
|
|
||||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
||||||
const playMode = !!((ctx || {})._forge_play_mode);
|
const playMode = !!ctx?._forge_play_mode;
|
||||||
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
||||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||||
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
|
if (boundCtx) ctx = { ...ctx, ...boundCtx };
|
||||||
|
|
||||||
// meta d'un widget à special_render : fixedAttrs propre (voir
|
// meta d'un widget à special_render : fixedAttrs propre (voir
|
||||||
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
||||||
@@ -319,7 +319,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
|
const safeContent = content ? forgeHtmlEscape(content).replaceAll('\n', '<br>') : '';
|
||||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
function forgeHexToRgb(hexColor) {
|
function forgeHexToRgb(hexColor) {
|
||||||
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
||||||
if (hex.length !== 6) hex = '5b8cff';
|
if (hex.length !== 6) hex = '5b8cff';
|
||||||
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
|
return [
|
||||||
|
Number.parseInt(hex.substr(0, 2), 16),
|
||||||
|
Number.parseInt(hex.substr(2, 2), 16),
|
||||||
|
Number.parseInt(hex.substr(4, 2), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrait de forgeInterpolateColor (lot 4 "modernisation JS", S7721) :
|
||||||
|
// fonction pure (aucune fermeture sur lo/hi/mixed/ratio), pas besoin
|
||||||
|
// d'être redéfinie à chaque appel.
|
||||||
|
function forgeByteToHex(n) {
|
||||||
|
return n.toString(16).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||||
const lo = forgeHexToRgb(lowHex); const
|
const lo = forgeHexToRgb(lowHex); const
|
||||||
hi = forgeHexToRgb(highHex);
|
hi = forgeHexToRgb(highHex);
|
||||||
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
||||||
const toHex = function (n) { return n.toString(16).padStart(2, '0'); };
|
return `#${mixed.map(forgeByteToHex).join('')}`;
|
||||||
return `#${mixed.map(toHex).join('')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
||||||
@@ -23,14 +33,42 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
|
|||||||
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||||
if (!definitionId || !fieldName) return null;
|
if (!definitionId || !fieldName) return null;
|
||||||
definitionId = String(definitionId);
|
definitionId = String(definitionId);
|
||||||
const rows = gameData.data && gameData.data[definitionId];
|
const rows = gameData.data?.[definitionId];
|
||||||
if (!rows || !rows.length) return null;
|
if (!rows?.length) return null;
|
||||||
let row = null;
|
let row = null;
|
||||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||||
if (!row) row = rows[0];
|
if (!row) row = rows[0];
|
||||||
return row[fieldName];
|
return row[fieldName];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Label superposé qui affiche la valeur BRUTE du champ (indépendant du
|
||||||
|
// pourcentage/couleur) — extrait de forgeRenderJauge (lot 4 "modernisation
|
||||||
|
// JS", S3776).
|
||||||
|
function forgeJaugeValueLabelHtml(afficherValeur, rawValue) {
|
||||||
|
if (!afficherValeur) return '';
|
||||||
|
return '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||||
|
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||||
|
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||||
|
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label du NOM (ex. "Alice"), lu depuis un second champ optionnel — extrait
|
||||||
|
// de forgeRenderJauge.
|
||||||
|
function forgeJaugeNameLabelHtml(gameData, definitionId, rowId, fieldNameNom, nomPosition, nomAlignement, nomStyle) {
|
||||||
|
if (!fieldNameNom) return '';
|
||||||
|
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||||
|
if (nomValue === null || nomValue === undefined || nomValue === '') return '';
|
||||||
|
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||||
|
const {
|
||||||
|
nomPolice, nomTaille, nomGras, nomItalique,
|
||||||
|
} = nomStyle;
|
||||||
|
return `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||||
|
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||||
|
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||||
|
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||||
|
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const attrs = el.attributes || {};
|
const attrs = el.attributes || {};
|
||||||
const nomPosition = attrs._nom_position || 'dessus';
|
const nomPosition = attrs._nom_position || 'dessus';
|
||||||
@@ -47,14 +85,14 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
const rowId = attrs._row_id;
|
const rowId = attrs._row_id;
|
||||||
const fieldName = (attrs._champ || '').trim();
|
const fieldName = (attrs._champ || '').trim();
|
||||||
const fieldNameNom = (attrs._champ_nom || '').trim();
|
const fieldNameNom = (attrs._champ_nom || '').trim();
|
||||||
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
let minV = Number.parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||||
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
let maxV = Number.parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||||
const afficherValeur = attrs._afficher_valeur === '1';
|
const afficherValeur = attrs._afficher_valeur === '1';
|
||||||
|
|
||||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||||
let value = parseFloat(rawValue);
|
let value = Number.parseFloat(rawValue);
|
||||||
if (Number.isNaN(value)) value = minV;
|
if (Number.isNaN(value)) value = minV;
|
||||||
|
|
||||||
const span = (maxV - minV) || 1;
|
const span = (maxV - minV) || 1;
|
||||||
@@ -62,29 +100,22 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
const percent = Math.round(ratio * 1000) / 10;
|
const percent = Math.round(ratio * 1000) / 10;
|
||||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||||
|
|
||||||
let labelHtml = '';
|
const labelHtml = forgeJaugeValueLabelHtml(afficherValeur, rawValue);
|
||||||
if (afficherValeur) {
|
const nomStyle = {
|
||||||
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
nomPolice, nomTaille, nomGras, nomItalique,
|
||||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
};
|
||||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
const nameHtml = forgeJaugeNameLabelHtml(
|
||||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
gameData,
|
||||||
}
|
definitionId,
|
||||||
|
rowId,
|
||||||
let nameHtml = '';
|
fieldNameNom,
|
||||||
if (fieldNameNom) {
|
nomPosition,
|
||||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
nomAlignement,
|
||||||
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
|
nomStyle,
|
||||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
);
|
||||||
nameHtml = `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
|
||||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
|
||||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
|
||||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
|
||||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraClasses = Object.keys(attrs)
|
const extraClasses = Object.keys(attrs)
|
||||||
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
|
.filter((k) => k.startsWith('_class_') && attrs[k])
|
||||||
.map((k) => attrs[k]).join(' ');
|
.map((k) => attrs[k]).join(' ');
|
||||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
||||||
// mais le port reste fidèle pour ne pas diverger inutilement).
|
// mais le port reste fidèle pour ne pas diverger inutilement).
|
||||||
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const isPlayMode = !!(ctx && ctx._forge_play_mode);
|
const isPlayMode = !!ctx?._forge_play_mode;
|
||||||
const attrsRaw = el.attributes || {};
|
const attrsRaw = el.attributes || {};
|
||||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||||
const radius = attrsRaw._arrondi || '12';
|
const radius = attrsRaw._arrondi || '12';
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
|||||||
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
||||||
const templateTypeId = attrsRaw._template_type_id;
|
const templateTypeId = attrsRaw._template_type_id;
|
||||||
const template = forgeApplyCtx(el.content || '', ctx);
|
const template = forgeApplyCtx(el.content || '', ctx);
|
||||||
const children = (childrenMap && childrenMap[el.id]) || [];
|
const children = childrenMap?.[el.id] || [];
|
||||||
|
|
||||||
if (!definitionId) {
|
if (!definitionId) {
|
||||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||||
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
||||||
}
|
}
|
||||||
definitionId = String(definitionId);
|
definitionId = String(definitionId);
|
||||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||||
if (!fieldsMetaForDef) {
|
if (!fieldsMetaForDef) {
|
||||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
||||||
@@ -28,11 +28,11 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
|||||||
|
|
||||||
let templateTree = null;
|
let templateTree = null;
|
||||||
if (templateTypeId) {
|
if (templateTypeId) {
|
||||||
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
|
templateTree = gameData.element_type_templates?.[String(templateTypeId)];
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = rows.map((row) => {
|
const items = rows.map((row) => {
|
||||||
const rowCtx = { ...ctx || {} };
|
const rowCtx = { ...ctx };
|
||||||
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
||||||
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
||||||
// donc directement la bonne valeur, y compris pour un champ "relation"
|
// donc directement la bonne valeur, y compris pour un champ "relation"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
|||||||
|
|
||||||
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||||
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
|
const headerRow = ['1', 'true', 'on'].includes(el.attributes?._header_row);
|
||||||
const content = forgeApplyCtx(el.content || '', ctx);
|
const content = forgeApplyCtx(el.content || '', ctx);
|
||||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||||
const rows = content.split('\n').filter((r) => r.trim());
|
const rows = content.split('\n').filter((r) => r.trim());
|
||||||
@@ -83,7 +83,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
||||||
// "icone", posés directement ou nichés dans un élément de jeu
|
// "icone", posés directement ou nichés dans un élément de jeu
|
||||||
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
|
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
|
||||||
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
|
const iconUrl = gameData.icon_data_uris?.[iconSlug] || (`icons/${iconSlug}.svg`);
|
||||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
|
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
|
||||||
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
|
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
|
||||||
// description dédié dans l'éditeur) est traitée comme purement
|
// description dédié dans l'éditeur) est traitée comme purement
|
||||||
@@ -94,7 +94,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const tabs = (childrenMap && childrenMap[el.id]) || [];
|
const tabs = childrenMap?.[el.id] || [];
|
||||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||||
const tabsExtraClasses = attrs.class || '';
|
const tabsExtraClasses = attrs.class || '';
|
||||||
delete attrs.class;
|
delete attrs.class;
|
||||||
@@ -138,9 +138,9 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
|
|||||||
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||||
attrs['data-personnage'] = '1';
|
attrs['data-personnage'] = '1';
|
||||||
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
const animations = gameData.personnage_animations?.[String(el.id)];
|
||||||
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||||
const idleFrame = frames && frames.length ? frames[0] : '';
|
const idleFrame = frames?.length ? frames[0] : '';
|
||||||
if (idleFrame) attrs.src = idleFrame;
|
if (idleFrame) attrs.src = idleFrame;
|
||||||
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
||||||
// décoratif sur le widget "image" générique.
|
// décoratif sur le widget "image" générique.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function forgeXapiActor() {
|
|||||||
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
|
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
|
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ } // NOSONAR S2486, CODE_QUALITY.md
|
||||||
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
|
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ function forgeXapiSendStatement(verbId, result, object) {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
if (result) statement.result = result;
|
if (result) statement.result = result;
|
||||||
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`;
|
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`; // NOSONAR S8786 - un seul quantificateur sur 1 litteral, teste jusqu'a 1M car. sans blowup (voir CODE_QUALITY.md)
|
||||||
try {
|
try {
|
||||||
fetch(endpoint, {
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ let activePersonnageInteractListener = null;
|
|||||||
const FORGE_PERSONNAGE_FPS = 8;
|
const FORGE_PERSONNAGE_FPS = 8;
|
||||||
|
|
||||||
function forgePersonnageAnimExists(elementId, animName) {
|
function forgePersonnageAnimExists(elementId, animName) {
|
||||||
const byAnim = (gameData.personnage_animations || {})[elementId];
|
const byAnim = gameData.personnage_animations?.[elementId];
|
||||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
return !!byAnim?.[animName]?.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
|
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
|
||||||
@@ -81,10 +81,10 @@ function forgeUpdateSceneCamera(screenData) {
|
|||||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||||
const px = playerEl
|
const px = playerEl
|
||||||
? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2
|
? (Number.parseFloat(playerEl.style.left) || 0) + (Number.parseFloat(playerEl.style.width) || 0) / 2
|
||||||
: world.width / 2;
|
: world.width / 2;
|
||||||
const py = playerEl
|
const py = playerEl
|
||||||
? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2
|
? (Number.parseFloat(playerEl.style.top) || 0) + (Number.parseFloat(playerEl.style.height) || 0) / 2
|
||||||
: world.height / 2;
|
: world.height / 2;
|
||||||
const offsetX = Math.min(
|
const offsetX = Math.min(
|
||||||
Math.max(px - screenData.scene_width / 2, 0),
|
Math.max(px - screenData.scene_width / 2, 0),
|
||||||
@@ -125,7 +125,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
|||||||
// objet dont la collision est explicitement désactivée, ni l'objet qui
|
// objet dont la collision est explicitement désactivée, ni l'objet qui
|
||||||
// se déplace lui-même.
|
// se déplace lui-même.
|
||||||
function forgeSolidObstacles(screenData, movingId) {
|
function forgeSolidObstacles(screenData, movingId) {
|
||||||
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false));
|
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && o.collision?.enabled !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
|
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
|
||||||
@@ -139,90 +139,118 @@ function forgeSolidObstacles(screenData, movingId) {
|
|||||||
function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) {
|
function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) {
|
||||||
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
|
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
|
||||||
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
||||||
for (let i = 0; i < obstacles.length; i++) {
|
for (const other of obstacles) {
|
||||||
const other = obstacles[i];
|
|
||||||
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
||||||
if (!otherEl) continue;
|
if (!otherEl) continue;
|
||||||
const otherLeft = parseFloat(otherEl.style.left) || 0;
|
const otherLeft = Number.parseFloat(otherEl.style.left) || 0;
|
||||||
const otherTop = parseFloat(otherEl.style.top) || 0;
|
const otherTop = Number.parseFloat(otherEl.style.top) || 0;
|
||||||
const otherWidth = parseFloat(otherEl.style.width) || 0;
|
const otherWidth = Number.parseFloat(otherEl.style.width) || 0;
|
||||||
const otherHeight = parseFloat(otherEl.style.height) || 0;
|
const otherHeight = Number.parseFloat(otherEl.style.height) || 0;
|
||||||
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
||||||
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Déplacement BRUT tenté (avant collision), selon l'axe autorisé par cmd.axe
|
||||||
|
// — extrait de forgeRunPersonnageControllerTick (lot 4 "modernisation JS", S3776).
|
||||||
|
function forgeComputeMovementDelta(cmd) {
|
||||||
|
let rawDx = 0; let
|
||||||
|
rawDy = 0;
|
||||||
|
if (cmd.axe !== 'vertical') {
|
||||||
|
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||||
|
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||||
|
}
|
||||||
|
if (cmd.axe !== 'horizontal') {
|
||||||
|
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
||||||
|
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
|
||||||
|
}
|
||||||
|
return { rawDx, rawDy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un seul
|
||||||
|
// est gêné) — glisser le long d'un mur en diagonale reste possible, plus
|
||||||
|
// naturel qu'un blocage total au moindre contact. Extrait de
|
||||||
|
// forgeRunPersonnageControllerTick.
|
||||||
|
// `box` = {left, top, width, height} (position/taille ACTUELLES, avant
|
||||||
|
// déplacement) ; `rawDelta` = {rawDx, rawDy} (voir forgeComputeMovementDelta)
|
||||||
|
// — regroupés en objets pour rester sous la limite de 7 paramètres (S107),
|
||||||
|
// plutôt que 9 paramètres positionnels distincts.
|
||||||
|
function forgeResolveBlockedMovement(screenData, obj, targetEl, box, rawDelta) {
|
||||||
|
const {
|
||||||
|
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
|
||||||
|
} = box;
|
||||||
|
let { rawDx: dx, rawDy: dy } = rawDelta;
|
||||||
|
if (dx !== 0) {
|
||||||
|
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||||
|
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||||
|
}
|
||||||
|
if (dy !== 0) {
|
||||||
|
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||||
|
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
||||||
|
}
|
||||||
|
return { dx, dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anime idle (immobile) ou walk (déplacement effectif) — même fps pour les
|
||||||
|
// deux (bug corrigé par l'utilisateur : idle tournait à 4 i/s contre 8
|
||||||
|
// pour walk, aucune raison réelle de les désynchroniser). Extrait de
|
||||||
|
// forgeRunPersonnageControllerTick.
|
||||||
|
function forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy) {
|
||||||
|
if (dx === 0 && dy === 0) {
|
||||||
|
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||||
|
if (idleAnim) {
|
||||||
|
runSpriteAnimation(
|
||||||
|
targetEl,
|
||||||
|
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||||
|
if (moveAnim) {
|
||||||
|
runSpriteAnimation(
|
||||||
|
targetEl,
|
||||||
|
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déplace/anime UN personnage joueur pour ce tick — extrait du forEach de
|
||||||
|
// forgeRunPersonnageControllerTick.
|
||||||
|
function forgeRunPersonnagePlayerTick(screenData, obj) {
|
||||||
|
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
||||||
|
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
||||||
|
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
||||||
|
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||||
|
const cmd = obj.personnage_commandes;
|
||||||
|
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||||
|
if (!targetEl) return;
|
||||||
|
|
||||||
|
const { rawDx, rawDy } = forgeComputeMovementDelta(cmd);
|
||||||
|
const curLeft = Number.parseFloat(targetEl.style.left) || 0;
|
||||||
|
const curTop = Number.parseFloat(targetEl.style.top) || 0;
|
||||||
|
const boxWidth = Number.parseFloat(targetEl.style.width) || 0;
|
||||||
|
const boxHeight = Number.parseFloat(targetEl.style.height) || 0;
|
||||||
|
const box = {
|
||||||
|
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
|
||||||
|
};
|
||||||
|
const { dx, dy } = forgeResolveBlockedMovement(screenData, obj, targetEl, box, { rawDx, rawDy });
|
||||||
|
|
||||||
|
// Se tourne vers la direction TENTÉE même si le déplacement est
|
||||||
|
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
|
||||||
|
// personnage qui reste dos tourné contre ce qui le bloque).
|
||||||
|
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
|
||||||
|
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||||
|
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||||
|
forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||||
// (triggers.js), pour un déplacement perçu comme continu.
|
// (triggers.js), pour un déplacement perçu comme continu.
|
||||||
function forgeRunPersonnageControllerTick(screenData) {
|
function forgeRunPersonnageControllerTick(screenData) {
|
||||||
forgeUpdateSceneCamera(screenData);
|
forgeUpdateSceneCamera(screenData);
|
||||||
(screenData.elements || []).forEach((obj) => {
|
(screenData.elements || []).forEach((obj) => forgeRunPersonnagePlayerTick(screenData, obj));
|
||||||
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
|
||||||
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
|
||||||
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
|
||||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
|
||||||
const cmd = obj.personnage_commandes;
|
|
||||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
|
||||||
if (!targetEl) return;
|
|
||||||
|
|
||||||
let rawDx = 0; let
|
|
||||||
rawDy = 0;
|
|
||||||
if (cmd.axe !== 'vertical') {
|
|
||||||
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
|
||||||
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
|
||||||
}
|
|
||||||
if (cmd.axe !== 'horizontal') {
|
|
||||||
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
|
||||||
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un
|
|
||||||
// seul est gêné) — glisser le long d'un mur en diagonale reste
|
|
||||||
// possible, plus naturel qu'un blocage total au moindre contact.
|
|
||||||
const curLeft = parseFloat(targetEl.style.left) || 0;
|
|
||||||
const curTop = parseFloat(targetEl.style.top) || 0;
|
|
||||||
const boxWidth = parseFloat(targetEl.style.width) || 0;
|
|
||||||
const boxHeight = parseFloat(targetEl.style.height) || 0;
|
|
||||||
let dx = rawDx; let
|
|
||||||
dy = rawDy;
|
|
||||||
if (dx !== 0) {
|
|
||||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
|
||||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
|
||||||
}
|
|
||||||
if (dy !== 0) {
|
|
||||||
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
|
||||||
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
|
||||||
}
|
|
||||||
// Se tourne vers la direction TENTÉE même si le déplacement est
|
|
||||||
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
|
|
||||||
// personnage qui reste dos tourné contre ce qui le bloque).
|
|
||||||
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
|
|
||||||
|
|
||||||
// Même fps pour idle et walk (bug signalé par l'utilisateur : idle
|
|
||||||
// tournait à 4 i/s contre 8 pour walk, perçue comme "les autres
|
|
||||||
// animations sont lentes à côté de la marche" — aucune raison réelle
|
|
||||||
// de les désynchroniser).
|
|
||||||
if (dx === 0 && dy === 0) {
|
|
||||||
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
|
||||||
if (idleAnim) {
|
|
||||||
runSpriteAnimation(
|
|
||||||
targetEl,
|
|
||||||
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
|
||||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
|
||||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
|
||||||
if (moveAnim) {
|
|
||||||
runSpriteAnimation(
|
|
||||||
targetEl,
|
|
||||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
|
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
|
||||||
@@ -261,7 +289,7 @@ function forgeStartPersonnageInteractListener(screenData) {
|
|||||||
// réinitialisé à chaque changement d'écran (voir showScreen()).
|
// réinitialisé à chaque changement d'écran (voir showScreen()).
|
||||||
function forgeStartPersonnageControllers(screenData) {
|
function forgeStartPersonnageControllers(screenData) {
|
||||||
forgeStopPersonnageControllers();
|
forgeStopPersonnageControllers();
|
||||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
if (screenData?.kind !== 'jeu_2d') return;
|
||||||
const elements = screenData.elements || [];
|
const elements = screenData.elements || [];
|
||||||
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||||
const hasFond = elements.some((o) => o.kind === 'fond');
|
const hasFond = elements.some((o) => o.kind === 'fond');
|
||||||
|
|||||||
@@ -15,27 +15,38 @@
|
|||||||
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
||||||
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
||||||
// classique (pas de module), lisibles ici telles quelles.
|
// classique (pas de module), lisibles ici telles quelles.
|
||||||
|
// Extrait de forgeQuizProgressOnShow (lot 4 "modernisation JS", S7721) :
|
||||||
|
// fonction pure (aucune fermeture), pas besoin d'être redéfinie à chaque appel.
|
||||||
|
function isQuestionLine(line) { return line?.type === 'question'; }
|
||||||
|
|
||||||
|
// Extrait de forgeQuizProgressOnShow (S3358) : segment "fait"/"courant"/
|
||||||
|
// "à venir" de la barre d'étapes pour la question d'indice `i` (1-based).
|
||||||
|
function forgeQuizProgressBarSegmentClass(i, current) {
|
||||||
|
if (i < current) return 'is-done';
|
||||||
|
if (i === current) return 'is-current';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// `progressSelector` : chaque modèle a son propre conteneur de barre
|
// `progressSelector` : chaque modèle a son propre conteneur de barre
|
||||||
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
||||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||||
if (!quizState) return;
|
if (!quizState) return;
|
||||||
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||||
if (!dialogueState) return;
|
if (!dialogueState) return;
|
||||||
function isQuestion(line) { return line && line.type === 'question'; }
|
const total = dialogueState.lines.filter(isQuestionLine).length;
|
||||||
const total = dialogueState.lines.filter(isQuestion).length;
|
|
||||||
if (total === 0) return;
|
if (total === 0) return;
|
||||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestionLine).length;
|
||||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||||
if (header) header.textContent = `Question ${current}/${total}`;
|
if (header) header.textContent = `Question ${current}/${total}`;
|
||||||
const progressEl = widget.querySelector(progressSelector);
|
const progressEl = widget.querySelector(progressSelector);
|
||||||
if (progressEl) {
|
if (progressEl) {
|
||||||
const bars = [];
|
const bars = [];
|
||||||
for (let i = 1; i <= total; i++) {
|
for (let i = 1; i <= total; i++) {
|
||||||
bars.push(`<i class="${i < current ? 'is-done' : (i === current ? 'is-current' : '')}"></i>`);
|
bars.push(`<i class="${forgeQuizProgressBarSegmentClass(i, current)}"></i>`);
|
||||||
}
|
}
|
||||||
progressEl.innerHTML = bars.join('');
|
progressEl.innerHTML = bars.join('');
|
||||||
}
|
}
|
||||||
@@ -79,15 +90,15 @@ function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
|||||||
const content = widget.querySelector('.classiqueContent');
|
const content = widget.querySelector('.classiqueContent');
|
||||||
const zone = widget.querySelector('[data-classique-role="result"]');
|
const zone = widget.querySelector('[data-classique-role="result"]');
|
||||||
if (!content || !zone) return;
|
if (!content || !zone) return;
|
||||||
const total = (result && result.totalCount) || 0;
|
const total = result?.totalCount || 0;
|
||||||
const correct = (result && result.correctCount) || 0;
|
const correct = result?.correctCount || 0;
|
||||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||||
const passed = pct >= threshold;
|
const passed = pct >= threshold;
|
||||||
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||||
const title = titleEl ? titleEl.textContent : 'Quiz';
|
const title = titleEl ? titleEl.textContent : 'Quiz';
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
zone.innerHTML = `<div class="classiqueResultRing" style="--pct:${pct}"><span>${pct}%</span></div>`
|
zone.innerHTML = `<div class="classiqueResultRing" style="--pct:${pct}"><span>${pct}%</span></div>`
|
||||||
+ `<p class="classiqueResultTitle">${correct} bonne${correct > 1 ? 's' : ''} réponse${correct > 1 ? 's' : ''} sur ${total}</p>`
|
+ `<p class="classiqueResultTitle">${correct} bonne${correct > 1 ? 's' : ''} réponse${correct > 1 ? 's' : ''} sur ${total}</p>`
|
||||||
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
||||||
@@ -112,13 +123,13 @@ function forgeQuizMangaOnQuizComplete(widget, result) {
|
|||||||
if (!widget) return;
|
if (!widget) return;
|
||||||
const zone = widget.querySelector('[data-manga-role="result"]');
|
const zone = widget.querySelector('[data-manga-role="result"]');
|
||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
const total = (result && result.totalCount) || 0;
|
const total = result?.totalCount || 0;
|
||||||
const correct = (result && result.correctCount) || 0;
|
const correct = result?.correctCount || 0;
|
||||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||||
const passed = pct >= threshold;
|
const passed = pct >= threshold;
|
||||||
const quizObjectId = widget.getAttribute('data-object-id');
|
const quizObjectId = widget.dataset.objectId;
|
||||||
zone.innerHTML = '<div class="mangaResultCard">'
|
zone.innerHTML = '<div class="mangaResultCard">'
|
||||||
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
||||||
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
function forgeRunScreenTriggers(screenData) {
|
function forgeRunScreenTriggers(screenData) {
|
||||||
if (!screenData) return;
|
if (!screenData) return;
|
||||||
const rules = screenData.screen_triggers;
|
const rules = screenData.screen_triggers;
|
||||||
if (!rules || !rules.length) return;
|
if (!rules?.length) return;
|
||||||
rules.forEach((rule) => {
|
rules.forEach((rule) => {
|
||||||
if (rule.trigger !== 'affichage') return;
|
if (rule.trigger !== 'affichage') return;
|
||||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ function runScreenBackgroundMusic(screenId) {
|
|||||||
currentBackgroundAudio = null;
|
currentBackgroundAudio = null;
|
||||||
}
|
}
|
||||||
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
||||||
const url = screenData && screenData.background_music_url;
|
const url = screenData?.background_music_url;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
currentBackgroundAudio = new Audio(url);
|
currentBackgroundAudio = new Audio(url);
|
||||||
currentBackgroundAudio.loop = true;
|
currentBackgroundAudio.loop = true;
|
||||||
currentBackgroundAudio.play().catch(() => {});
|
currentBackgroundAudio.play().catch(() => {}); // NOSONAR S2486 - autoplay bloque, voir CODE_QUALITY.md
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voir le commentaire sur #playFrame dans le <style> de play.html : la
|
// Voir le commentaire sur #playFrame dans le <style> de play.html : la
|
||||||
@@ -94,8 +94,8 @@ function runScreenBackgroundMusic(screenId) {
|
|||||||
// que soit la forme réelle de la fenêtre du joueur.
|
// que soit la forme réelle de la fenêtre du joueur.
|
||||||
function applyScreenAspect(aspect) {
|
function applyScreenAspect(aspect) {
|
||||||
const parts = String(aspect).split('/');
|
const parts = String(aspect).split('/');
|
||||||
const w = parseFloat(parts[0]) || 9; const
|
const w = Number.parseFloat(parts[0]) || 9; const
|
||||||
h = parseFloat(parts[1]) || 16;
|
h = Number.parseFloat(parts[1]) || 16;
|
||||||
const frame = document.getElementById('playFrame');
|
const frame = document.getElementById('playFrame');
|
||||||
if (frame) {
|
if (frame) {
|
||||||
frame.style.setProperty('--ratio-w', w);
|
frame.style.setProperty('--ratio-w', w);
|
||||||
@@ -189,7 +189,16 @@ function applyAnimationClip(clip) {
|
|||||||
// assigné, gameData.personnage_animations, pas figées au moment où
|
// assigné, gameData.personnage_animations, pas figées au moment où
|
||||||
// le clip a été configuré).
|
// le clip a été configuré).
|
||||||
let spriteData = {};
|
let spriteData = {};
|
||||||
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ }
|
try {
|
||||||
|
spriteData = JSON.parse(clip.custom_keyframes || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||||
|
// custom_keyframes est produit par l'éditeur, jamais tapé à la
|
||||||
|
// main — un JSON invalide ici trahit presque toujours un bug côté
|
||||||
|
// éditeur, pas une action ponctuelle. Comportement joueur inchangé :
|
||||||
|
// l'animation reste silencieusement absente (spriteData reste {}).
|
||||||
|
console.warn('configuration sprite invalide', e);
|
||||||
|
}
|
||||||
spriteData.loop = infinite;
|
spriteData.loop = infinite;
|
||||||
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
||||||
} else {
|
} else {
|
||||||
@@ -222,12 +231,12 @@ function collectAnimationClips(screenId) {
|
|||||||
function visit(sid) {
|
function visit(sid) {
|
||||||
if (visited[sid]) return;
|
if (visited[sid]) return;
|
||||||
visited[sid] = true;
|
visited[sid] = true;
|
||||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
clips = clips.concat(gameData.animations?.[String(sid)] || []);
|
||||||
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
||||||
if (!screenData) return;
|
if (!screenData) return;
|
||||||
screenData.elements.forEach((el) => {
|
screenData.elements.forEach((el) => {
|
||||||
if (!el.element_type_id) return;
|
if (!el.element_type_id) return;
|
||||||
const templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
const templateScreenId = gameData.element_types?.[String(el.element_type_id)];
|
||||||
if (templateScreenId != null) visit(templateScreenId);
|
if (templateScreenId != null) visit(templateScreenId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function bindHoverTexts() {
|
|||||||
// Espace déclenchent le même comportement que le clic.
|
// Espace déclenchent le même comportement que le clic.
|
||||||
function bindClicks() {
|
function bindClicks() {
|
||||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
const elementId = Number.parseInt(elDiv.dataset.elementId, 10);
|
||||||
const triggers = findTriggerNodess(elementId, 'clic');
|
const triggers = findTriggerNodess(elementId, 'clic');
|
||||||
if (triggers.length) {
|
if (triggers.length) {
|
||||||
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
||||||
@@ -133,8 +133,9 @@ function bindClicks() {
|
|||||||
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
|
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
|
||||||
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
||||||
const rowEl = ev.target.closest('.repeaterItem');
|
const rowEl = ev.target.closest('.repeaterItem');
|
||||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
window.lastClickedRowId = rowEl ? Number.parseInt(rowEl.dataset.rowId, 10) : null;
|
||||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
window.lastClickedDefinitionId = elDiv.dataset.definitionId
|
||||||
|
? Number.parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||||
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||||
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||||
@@ -165,7 +166,7 @@ function bindClicks() {
|
|||||||
function bindHoverTriggers() {
|
function bindHoverTriggers() {
|
||||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||||
if (elDiv.dataset.hoverTriggerBound) return;
|
if (elDiv.dataset.hoverTriggerBound) return;
|
||||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
const elementId = Number.parseInt(elDiv.dataset.elementId, 10);
|
||||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||||
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
||||||
if (!enterTriggers.length && !leaveTriggers.length) return;
|
if (!enterTriggers.length && !leaveTriggers.length) return;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function makeEl() {
|
|||||||
getAttribute() { return null; },
|
getAttribute() { return null; },
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
|
dataset: {},
|
||||||
};
|
};
|
||||||
Object.defineProperty(el, 'innerHTML', {
|
Object.defineProperty(el, 'innerHTML', {
|
||||||
get() { return this._html; },
|
get() { return this._html; },
|
||||||
@@ -48,6 +49,9 @@ function setupGlobals() {
|
|||||||
global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' };
|
global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' };
|
||||||
global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' };
|
global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' };
|
||||||
global.USER_ASSETS_OPTIONS = [];
|
global.USER_ASSETS_OPTIONS = [];
|
||||||
|
// Nécessaire depuis le chargement de scene-editor.js (voir plus bas) :
|
||||||
|
// initScenePosFields() la lit dès son propre appel top-level.
|
||||||
|
global.CURRENT_SELECTED_ID = null;
|
||||||
|
|
||||||
const elementsById = {};
|
const elementsById = {};
|
||||||
global.document = {
|
global.document = {
|
||||||
@@ -75,6 +79,11 @@ function setupGlobals() {
|
|||||||
setupGlobals();
|
setupGlobals();
|
||||||
loadAsScript('../collision-rules-editor.js');
|
loadAsScript('../collision-rules-editor.js');
|
||||||
loadAsScript('../../triggers/trigger-editor.js');
|
loadAsScript('../../triggers/trigger-editor.js');
|
||||||
|
// scene-editor.js APRÈS trigger-editor.js, exactement l'ordre de chargement
|
||||||
|
// réel de templates/scene_edit.html (voir CODE_QUALITY.md, S2703) —
|
||||||
|
// nécessaire pour tester refreshSceneObjectNames() en conditions réelles,
|
||||||
|
// contre la vraie déclaration `SCENE_OBJECT_NAMES` de trigger-editor.js.
|
||||||
|
loadAsScript('../scene-editor.js');
|
||||||
|
|
||||||
test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES DEUX, dans le bon ordre', () => {
|
test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES DEUX, dans le bon ordre', () => {
|
||||||
// Bug corrigé : "Rien de plus" renvoyait la DERNIÈRE feuille ajoutée
|
// Bug corrigé : "Rien de plus" renvoyait la DERNIÈRE feuille ajoutée
|
||||||
@@ -212,3 +221,27 @@ test('triggerActionChainHtml — un bouton "+ Ajouter une action" apparaît apr
|
|||||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('a_1')"));
|
assert.ok(html.includes("screenTriggerOpenAppendActionModal('a_1')"));
|
||||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('d_1')"));
|
assert.ok(html.includes("screenTriggerOpenAppendActionModal('d_1')"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshSceneObjectNames — SCENE_OBJECT_NAMES doit rester réassignable depuis scene-editor.js (bug S2703 : déclarée en `const` dans trigger-editor.js, la réassignation levait TypeError et la liste ne se mettait jamais à jour sans recharger la page)', async () => {
|
||||||
|
setupGlobals();
|
||||||
|
global.fetch = function (url) {
|
||||||
|
assert.equal(url, '/game/pytest_wizard/scene-object-names');
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(['Marc', 'Damien']) });
|
||||||
|
};
|
||||||
|
|
||||||
|
global.refreshSceneObjectNames();
|
||||||
|
// fetch(...).then(...).then(...) est asynchrone (2 sauts de microtâches) —
|
||||||
|
// laisse la file d'attente se vider avant de vérifier le résultat.
|
||||||
|
await new Promise((resolve) => { setTimeout(resolve, 0); });
|
||||||
|
|
||||||
|
// SCENE_OBJECT_NAMES est déclarée en `let`/`const` (pas `var`) : comme
|
||||||
|
// dans un vrai navigateur, cette liaison ne devient JAMAIS une propriété
|
||||||
|
// de l'objet global — il faut la relire via une autre exécution dans le
|
||||||
|
// même environnement lexical partagé (vm.runInThisContext), pas via
|
||||||
|
// `global.SCENE_OBJECT_NAMES` (toujours `undefined`, quel que soit le
|
||||||
|
// résultat réel du bug).
|
||||||
|
assert.deepEqual(vm.runInThisContext('SCENE_OBJECT_NAMES'), ['Marc', 'Damien']);
|
||||||
|
// Preuve que triggerSpeakerOptions() (trigger-editor.js) voit bien le
|
||||||
|
// nouveau nom SANS rechargement de page — exactement le symptôme rapporté.
|
||||||
|
assert.ok(global.triggerSpeakerOptions(null).includes('Damien'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const COLLISION_TRIGGER_LABELS = {
|
|||||||
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||||
// pour celles-ci.
|
// pour celles-ci.
|
||||||
const COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
const COLLISION_NO_VALUE_OPERATIONS = new Set(['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool']);
|
||||||
|
|
||||||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||||
@@ -242,7 +242,7 @@ function renderCollisionWizardAttendreStep(onChoose) {
|
|||||||
|
|
||||||
function collisionWizardAttendreNext() {
|
function collisionWizardAttendreNext() {
|
||||||
const raw = document.getElementById('attendreWizSecondes').value;
|
const raw = document.getElementById('attendreWizSecondes').value;
|
||||||
const secondes = String(Math.max(0, parseFloat(raw) || 0));
|
const secondes = String(Math.max(0, Number.parseFloat(raw) || 0));
|
||||||
const onChoose = _collisionWizard.onAttendreChosen;
|
const onChoose = _collisionWizard.onAttendreChosen;
|
||||||
_collisionWizard.onAttendreChosen = null;
|
_collisionWizard.onAttendreChosen = null;
|
||||||
onChoose(secondes);
|
onChoose(secondes);
|
||||||
@@ -265,7 +265,7 @@ function renderCollisionWizardObjectTargetStep(onChoose) {
|
|||||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||||
grid.innerHTML = objects.map((o, i) => `<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:${i * 40}ms" onclick="collisionWizardObjectTargetChosen(${o.id})">`
|
grid.innerHTML = objects.map((o, i) => `<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:${i * 40}ms" onclick="collisionWizardObjectTargetChosen(${o.id})">`
|
||||||
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
||||||
+ `<span>${(o.name || '').replace(/</g, '<')}</span>`
|
+ `<span>${(o.name || '').replaceAll('<', '<')}</span>`
|
||||||
+ '</button>').join('');
|
+ '</button>').join('');
|
||||||
_collisionWizard.onObjectTargetChosen = onChoose;
|
_collisionWizard.onObjectTargetChosen = onChoose;
|
||||||
});
|
});
|
||||||
@@ -314,7 +314,7 @@ function renderCollisionWizardAssetStep(onChoose, kind) {
|
|||||||
collisionWizardBodyEl().innerHTML = `<p class="hint mb-2">Aucun fichier ${label} dans "Mes assets" pour l'instant — importe-en un depuis le panneau de gauche (section "Mes assets"), puis recommence.</p>`;
|
collisionWizardBodyEl().innerHTML = `<p class="hint mb-2">Aucun fichier ${label} dans "Mes assets" pour l'instant — importe-en un depuis le panneau de gauche (section "Mes assets"), puis recommence.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = assets.map((a) => `<option value="${a.url}">${(a.name || '').replace(/</g, '<')}</option>`).join('');
|
const options = assets.map((a) => `<option value="${a.url}">${(a.name || '').replaceAll('<', '<')}</option>`).join('');
|
||||||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel fichier (voir "Mes assets") ?</p>'
|
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel fichier (voir "Mes assets") ?</p>'
|
||||||
+ `<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">${options}</select>`
|
+ `<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">${options}</select>`
|
||||||
+ '<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
+ '<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
||||||
@@ -364,7 +364,7 @@ function collisionWizardIndicationNext() {
|
|||||||
const action = {
|
const action = {
|
||||||
type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte,
|
type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte,
|
||||||
};
|
};
|
||||||
if (dureeRaw) action.duree_ms = parseInt(dureeRaw, 10);
|
if (dureeRaw) action.duree_ms = Number.parseInt(dureeRaw, 10);
|
||||||
_collisionWizard.pendingIndicationObjectId = null;
|
_collisionWizard.pendingIndicationObjectId = null;
|
||||||
const onChoose = _collisionWizard.onIndicationChosen;
|
const onChoose = _collisionWizard.onIndicationChosen;
|
||||||
_collisionWizard.onIndicationChosen = null;
|
_collisionWizard.onIndicationChosen = null;
|
||||||
@@ -418,7 +418,7 @@ function renderCollisionWizardVariableStep(onChoose) {
|
|||||||
function collisionWizardVariableOperationChanged() {
|
function collisionWizardVariableOperationChanged() {
|
||||||
const operation = document.getElementById('varWizOperation').value;
|
const operation = document.getElementById('varWizOperation').value;
|
||||||
const valueInput = document.getElementById('varWizValue');
|
const valueInput = document.getElementById('varWizValue');
|
||||||
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1 ? '' : 'none';
|
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.has(operation) ? 'none' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function collisionWizardVariableNext() {
|
function collisionWizardVariableNext() {
|
||||||
@@ -429,7 +429,7 @@ function collisionWizardVariableNext() {
|
|||||||
target_variable: document.getElementById('varWizVariable').value,
|
target_variable: document.getElementById('varWizVariable').value,
|
||||||
data_operation: operation,
|
data_operation: operation,
|
||||||
};
|
};
|
||||||
if (COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1) {
|
if (!COLLISION_NO_VALUE_OPERATIONS.has(operation)) {
|
||||||
action.data_value = document.getElementById('varWizValue').value;
|
action.data_value = document.getElementById('varWizValue').value;
|
||||||
}
|
}
|
||||||
const onChoose = _collisionWizard.onVariableChosen;
|
const onChoose = _collisionWizard.onVariableChosen;
|
||||||
@@ -456,9 +456,10 @@ function collisionWizardVariableNext() {
|
|||||||
// dialogue, "attendre" disparaissait silencieusement.
|
// dialogue, "attendre" disparaissait silencieusement.
|
||||||
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
||||||
headAction = headAction || leafAction;
|
headAction = headAction || leafAction;
|
||||||
|
const markCompletedChecked = leafAction.mark_completed ? ' checked' : '';
|
||||||
const completionHtml = leafAction.type === 'dialogue'
|
const completionHtml = leafAction.type === 'dialogue'
|
||||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">'
|
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">'
|
||||||
+ `<input type="checkbox" id="condWizMarkCompleted"${leafAction.mark_completed ? ' checked' : ''}> Marquer ce dialogue comme terminé une fois joué`
|
+ `<input type="checkbox" id="condWizMarkCompleted"${markCompletedChecked}> Marquer ce dialogue comme terminé une fois joué`
|
||||||
+ '</label>'
|
+ '</label>'
|
||||||
: '';
|
: '';
|
||||||
const choiceButtons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<button type="button" class="collisionWizardChoice" style="animation-delay:${(i + 1) * 40}ms" onclick="collisionWizardChainDone('${choice}')"><span>${COLLISION_ACTION_LABELS[choice]}</span></button>`).join('');
|
const choiceButtons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<button type="button" class="collisionWizardChoice" style="animation-delay:${(i + 1) * 40}ms" onclick="collisionWizardChainDone('${choice}')"><span>${COLLISION_ACTION_LABELS[choice]}</span></button>`).join('');
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function createGlobalVariable(event) {
|
|||||||
fetch(`/game/${GAME_SLUG}/variables/new`, {
|
fetch(`/game/${GAME_SLUG}/variables/new`, {
|
||||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||||||
})
|
})
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (!json.row_html) { window.location.reload(); return; }
|
if (!json.row_html) { window.location.reload(); return; }
|
||||||
const tbody = document.getElementById('globalVariablesBody');
|
const tbody = document.getElementById('globalVariablesBody');
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
|||||||
// posé en base à ce stade (voir screens/scenes/add_scene_object.py),
|
// posé en base à ce stade (voir screens/scenes/add_scene_object.py),
|
||||||
// seul un rechargement peut alors l'afficher.
|
// seul un rechargement peut alors l'afficher.
|
||||||
const contentType = r.headers.get('content-type') || '';
|
const contentType = r.headers.get('content-type') || '';
|
||||||
if (!r.ok || contentType.indexOf('application/json') === -1) {
|
if (!r.ok || !contentType.includes('application/json')) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ function onCanvasDrop(ev) {
|
|||||||
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
||||||
// qui n'ont rien à "poser".
|
// qui n'ont rien à "poser".
|
||||||
function uploadUserAsset(inputEl, sceneKind) {
|
function uploadUserAsset(inputEl, sceneKind) {
|
||||||
const file = inputEl.files && inputEl.files[0];
|
const file = inputEl.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append('file', file);
|
body.append('file', file);
|
||||||
@@ -193,14 +193,14 @@ function deleteSceneObject(id) {
|
|||||||
})
|
})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
const contentType = r.headers.get('content-type') || '';
|
const contentType = r.headers.get('content-type') || '';
|
||||||
if (!r.ok || contentType.indexOf('application/json') === -1) {
|
if (!r.ok || !contentType.includes('application/json')) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (!json || !json.ok) return;
|
if (!json?.ok) return;
|
||||||
const el = document.getElementById(`el-${id}`);
|
const el = document.getElementById(`el-${id}`);
|
||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||||
@@ -227,8 +227,8 @@ function deleteSceneObject(id) {
|
|||||||
function applyCameraSize() {
|
function applyCameraSize() {
|
||||||
const wEl = document.getElementById('scene-camera-w'); const
|
const wEl = document.getElementById('scene-camera-w'); const
|
||||||
hEl = document.getElementById('scene-camera-h');
|
hEl = document.getElementById('scene-camera-h');
|
||||||
const width = parseInt(wEl.value, 10) || 1; const
|
const width = Number.parseInt(wEl.value, 10) || 1; const
|
||||||
height = parseInt(hEl.value, 10) || 1;
|
height = Number.parseInt(hEl.value, 10) || 1;
|
||||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -325,10 +325,10 @@ function onSceneObjectMouseDown(e, id) {
|
|||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
let lastClientX = e.clientX; let
|
let lastClientX = e.clientX; let
|
||||||
lastClientY = e.clientY;
|
lastClientY = e.clientY;
|
||||||
let curLeft = parseFloat(el.style.left) || 0; let
|
let curLeft = Number.parseFloat(el.style.left) || 0; let
|
||||||
curTop = parseFloat(el.style.top) || 0;
|
curTop = Number.parseFloat(el.style.top) || 0;
|
||||||
const width = parseFloat(el.style.width) || 0; const
|
const width = Number.parseFloat(el.style.width) || 0; const
|
||||||
height = parseFloat(el.style.height) || 0;
|
height = Number.parseFloat(el.style.height) || 0;
|
||||||
let moved = false;
|
let moved = false;
|
||||||
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
||||||
// .floatPanel, z-index:60) restent DÉLIBÉRÉMENT en position:fixed
|
// .floatPanel, z-index:60) restent DÉLIBÉRÉMENT en position:fixed
|
||||||
@@ -405,10 +405,10 @@ function onSceneObjectResizeMouseDown(e, id) {
|
|||||||
const el = document.getElementById(`el-${id}`);
|
const el = document.getElementById(`el-${id}`);
|
||||||
let lastClientX = e.clientX; let
|
let lastClientX = e.clientX; let
|
||||||
lastClientY = e.clientY;
|
lastClientY = e.clientY;
|
||||||
let curWidth = parseFloat(el.style.width) || 0; let
|
let curWidth = Number.parseFloat(el.style.width) || 0; let
|
||||||
curHeight = parseFloat(el.style.height) || 0;
|
curHeight = Number.parseFloat(el.style.height) || 0;
|
||||||
const left = parseFloat(el.style.left) || 0; const
|
const left = Number.parseFloat(el.style.left) || 0; const
|
||||||
top = parseFloat(el.style.top) || 0;
|
top = Number.parseFloat(el.style.top) || 0;
|
||||||
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
||||||
// des panneaux flottants le temps du geste, pour rester visible même
|
// des panneaux flottants le temps du geste, pour rester visible même
|
||||||
// en redimensionnant sous l'un d'eux.
|
// en redimensionnant sous l'un d'eux.
|
||||||
@@ -459,10 +459,10 @@ function initScenePosFields() {
|
|||||||
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
||||||
function apply() {
|
function apply() {
|
||||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||||
const x = parseFloat(xEl.value) || 0; const
|
const x = Number.parseFloat(xEl.value) || 0; const
|
||||||
y = parseFloat(yEl.value) || 0;
|
y = Number.parseFloat(yEl.value) || 0;
|
||||||
const w = parseFloat(wEl.value) || 1; const
|
const w = Number.parseFloat(wEl.value) || 1; const
|
||||||
h = parseFloat(hEl.value) || 1;
|
h = Number.parseFloat(hEl.value) || 1;
|
||||||
if (canvasEl) {
|
if (canvasEl) {
|
||||||
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
||||||
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
||||||
@@ -483,7 +483,7 @@ initScenePosFields();
|
|||||||
function swapSceneObjectCharacter(forgeCharacter) {
|
function swapSceneObjectCharacter(forgeCharacter) {
|
||||||
const body = document.getElementById('personnagePanelBody');
|
const body = document.getElementById('personnagePanelBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
||||||
});
|
});
|
||||||
@@ -508,7 +508,7 @@ function swapSceneObjectCharacter(forgeCharacter) {
|
|||||||
function saveSceneObjectRole(role) {
|
function saveSceneObjectRole(role) {
|
||||||
const body = document.getElementById('personnagePanelBody');
|
const body = document.getElementById('personnagePanelBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/role`, {
|
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/role`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -527,19 +527,19 @@ function saveSceneObjectRole(role) {
|
|||||||
function saveSceneObjectCollision() {
|
function saveSceneObjectCollision() {
|
||||||
const body = document.getElementById('collisionPanelBody');
|
const body = document.getElementById('collisionPanelBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
const enabled = document.getElementById('collision-enabled').checked;
|
const enabled = document.getElementById('collision-enabled').checked;
|
||||||
const shape = document.getElementById('collision-shape').value;
|
const shape = document.getElementById('collision-shape').value;
|
||||||
const width = parseFloat(document.getElementById('collision-width').value) || 0;
|
const width = Number.parseFloat(document.getElementById('collision-width').value) || 0;
|
||||||
const height = parseFloat(document.getElementById('collision-height').value) || 0;
|
const height = Number.parseFloat(document.getElementById('collision-height').value) || 0;
|
||||||
const offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
const offsetX = Number.parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||||
const offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
const offsetY = Number.parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||||
|
|
||||||
const overlay = document.getElementById('collisionBoxOverlay');
|
const overlay = document.getElementById('collisionBoxOverlay');
|
||||||
const objEl = document.getElementById(`el-${objectId}`);
|
const objEl = document.getElementById(`el-${objectId}`);
|
||||||
if (overlay && objEl) {
|
if (overlay && objEl) {
|
||||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||||
objHeight = parseFloat(objEl.style.height) || 0;
|
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||||
overlay.style.width = `${width}px`;
|
overlay.style.width = `${width}px`;
|
||||||
overlay.style.height = `${height}px`;
|
overlay.style.height = `${height}px`;
|
||||||
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
||||||
@@ -579,14 +579,14 @@ function onCollisionBoxMouseDown(e, id) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const overlay = document.getElementById('collisionBoxOverlay');
|
const overlay = document.getElementById('collisionBoxOverlay');
|
||||||
const objEl = document.getElementById(`el-${id}`);
|
const objEl = document.getElementById(`el-${id}`);
|
||||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||||
objHeight = parseFloat(objEl.style.height) || 0;
|
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startLeft = parseFloat(overlay.style.left) || 0; const
|
const startLeft = Number.parseFloat(overlay.style.left) || 0; const
|
||||||
startTop = parseFloat(overlay.style.top) || 0;
|
startTop = Number.parseFloat(overlay.style.top) || 0;
|
||||||
const boxWidth = parseFloat(overlay.style.width) || 0; const
|
const boxWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||||||
boxHeight = parseFloat(overlay.style.height) || 0;
|
boxHeight = Number.parseFloat(overlay.style.height) || 0;
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
||||||
@@ -595,8 +595,8 @@ function onCollisionBoxMouseDown(e, id) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
const offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
const offsetX = (Number.parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||||
const offsetY = (parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
const offsetY = (Number.parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||||
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
||||||
saveSceneObjectCollision();
|
saveSceneObjectCollision();
|
||||||
}
|
}
|
||||||
@@ -614,14 +614,14 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const overlay = document.getElementById('collisionBoxOverlay');
|
const overlay = document.getElementById('collisionBoxOverlay');
|
||||||
const objEl = document.getElementById(`el-${id}`);
|
const objEl = document.getElementById(`el-${id}`);
|
||||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||||||
objHeight = parseFloat(objEl.style.height) || 0;
|
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startWidth = parseFloat(overlay.style.width) || 0; const
|
const startWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||||||
startHeight = parseFloat(overlay.style.height) || 0;
|
startHeight = Number.parseFloat(overlay.style.height) || 0;
|
||||||
const left = parseFloat(overlay.style.left) || 0; const
|
const left = Number.parseFloat(overlay.style.left) || 0; const
|
||||||
top = parseFloat(overlay.style.top) || 0;
|
top = Number.parseFloat(overlay.style.top) || 0;
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
||||||
@@ -630,8 +630,8 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
const newWidth = parseFloat(overlay.style.width); const
|
const newWidth = Number.parseFloat(overlay.style.width); const
|
||||||
newHeight = parseFloat(overlay.style.height);
|
newHeight = Number.parseFloat(overlay.style.height);
|
||||||
const offsetX = left - (objWidth - newWidth) / 2;
|
const offsetX = left - (objWidth - newWidth) / 2;
|
||||||
const offsetY = top - (objHeight - newHeight) / 2;
|
const offsetY = top - (objHeight - newHeight) / 2;
|
||||||
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
||||||
@@ -650,12 +650,12 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
|||||||
function saveSceneObjectCommands() {
|
function saveSceneObjectCommands() {
|
||||||
const body = document.getElementById('personnageCommandsBody');
|
const body = document.getElementById('personnageCommandsBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
const extra = [];
|
const extra = [];
|
||||||
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
||||||
const toucheEl = row.querySelector('.personnageKeyCapture');
|
const toucheEl = row.querySelector('.personnageKeyCapture');
|
||||||
const animEl = row.querySelector('.cmdExtraAnim');
|
const animEl = row.querySelector('.cmdExtraAnim');
|
||||||
if (toucheEl && toucheEl.value && animEl && animEl.value) {
|
if (toucheEl?.value && animEl?.value) {
|
||||||
extra.push({ touche: toucheEl.value, animation: animEl.value });
|
extra.push({ touche: toucheEl.value, animation: animEl.value });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -713,7 +713,7 @@ function personnageCommandExtraRowEl(objectId, touche, animation) {
|
|||||||
row.className = 'field is-grouped personnageCommandExtraRow';
|
row.className = 'field is-grouped personnageCommandExtraRow';
|
||||||
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
||||||
const options = animNames.map((name) => `<option value="${name}"${name === animation ? ' selected' : ''}>${name}</option>`).join('');
|
const options = animNames.map((name) => `<option value="${name}"${name === animation ? ' selected' : ''}>${name}</option>`).join('');
|
||||||
row.innerHTML = `<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="${(touche || '').replace(/"/g, '"')}"></div>`
|
row.innerHTML = `<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="${(touche || '').replaceAll('"', '"')}"></div>`
|
||||||
+ `<div class="control"><div class="select is-small"><select class="cmdExtraAnim">${options}</select></div></div>`
|
+ `<div class="control"><div class="select is-small"><select class="cmdExtraAnim">${options}</select></div></div>`
|
||||||
+ '<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
+ '<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
||||||
const keyInput = row.querySelector('.personnageKeyCapture');
|
const keyInput = row.querySelector('.personnageKeyCapture');
|
||||||
@@ -727,7 +727,7 @@ function addPersonnageCommandExtraRow() {
|
|||||||
const body = document.getElementById('personnageCommandsBody');
|
const body = document.getElementById('personnageCommandsBody');
|
||||||
const container = document.getElementById('personnageCommandsExtra');
|
const container = document.getElementById('personnageCommandsExtra');
|
||||||
if (!body || !container) return;
|
if (!body || !container) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +767,7 @@ function refreshSceneObjectNames() {
|
|||||||
function forgeHexToRgb(hex) {
|
function forgeHexToRgb(hex) {
|
||||||
hex = String(hex || '').replace('#', '');
|
hex = String(hex || '').replace('#', '');
|
||||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||||||
const num = parseInt(hex, 16);
|
const num = Number.parseInt(hex, 16);
|
||||||
if (Number.isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
if (Number.isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||||
}
|
}
|
||||||
@@ -820,7 +820,7 @@ function saveDialogueBoxStyle(objectId) {
|
|||||||
};
|
};
|
||||||
checkDialogueBoxContrast(style);
|
checkDialogueBoxContrast(style);
|
||||||
const el = document.getElementById(`el-${objectId}`);
|
const el = document.getElementById(`el-${objectId}`);
|
||||||
const widget = el && el.querySelector('.dialogueBoxWidget');
|
const widget = el?.querySelector('.dialogueBoxWidget');
|
||||||
if (widget) {
|
if (widget) {
|
||||||
widget.style.fontFamily = style.font_family;
|
widget.style.fontFamily = style.font_family;
|
||||||
widget.style.fontSize = `${style.font_size}px`;
|
widget.style.fontSize = `${style.font_size}px`;
|
||||||
@@ -847,10 +847,10 @@ function saveDialogueBoxStyle(objectId) {
|
|||||||
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.setAttribute('data-selected-template', key);
|
grid.dataset.selectedTemplate = key;
|
||||||
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
||||||
for (let i = 0; i < tiles.length; i++) {
|
for (const tile of tiles) {
|
||||||
tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key);
|
tile.classList.toggle('is-selected', tile.dataset.template === key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveQuizBoxConfig(objectId);
|
saveQuizBoxConfig(objectId);
|
||||||
@@ -887,10 +887,10 @@ function saveQuizBoxConfig(objectId) {
|
|||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
||||||
const selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
const selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
||||||
return grid.getAttribute('data-selected-template') || (selectedTile && selectedTile.getAttribute('data-template'));
|
return grid.dataset.selectedTemplate || selectedTile?.dataset.template;
|
||||||
}
|
}
|
||||||
const fullscreenEl = document.getElementById('quiz-fullscreen');
|
const fullscreenEl = document.getElementById('quiz-fullscreen');
|
||||||
const fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1');
|
const fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.dataset.wasChecked === '1');
|
||||||
const config = {
|
const config = {
|
||||||
fullscreen: fullscreenEl.checked,
|
fullscreen: fullscreenEl.checked,
|
||||||
timer_mode: document.getElementById('quiz-timer-mode').value,
|
timer_mode: document.getElementById('quiz-timer-mode').value,
|
||||||
@@ -907,7 +907,7 @@ function saveQuizBoxConfig(objectId) {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (fullscreenChanged) { window.location.reload(); return; }
|
if (fullscreenChanged) { window.location.reload(); return; }
|
||||||
const el = document.getElementById(`el-${objectId}`);
|
const el = document.getElementById(`el-${objectId}`);
|
||||||
const widget = el && el.querySelector('.quizBoxWidget');
|
const widget = el?.querySelector('.quizBoxWidget');
|
||||||
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -915,7 +915,7 @@ function saveQuizBoxConfig(objectId) {
|
|||||||
function initPersonnageCommandsPanel() {
|
function initPersonnageCommandsPanel() {
|
||||||
const body = document.getElementById('personnageCommandsBody');
|
const body = document.getElementById('personnageCommandsBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const objectId = parseInt(body.dataset.elementId, 10);
|
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach((id) => {
|
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach((id) => {
|
||||||
bindPersonnageKeyCapture(document.getElementById(id));
|
bindPersonnageKeyCapture(document.getElementById(id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ function initBuilderPanel() {
|
|||||||
if (!xEl || !wEl) return;
|
if (!xEl || !wEl) return;
|
||||||
function apply() {
|
function apply() {
|
||||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||||
const x = parseFloat(xEl.value) || 0; const
|
const x = Number.parseFloat(xEl.value) || 0; const
|
||||||
y = parseFloat(yEl.value) || 0;
|
y = Number.parseFloat(yEl.value) || 0;
|
||||||
const w = parseFloat(wEl.value) || 1; const
|
const w = Number.parseFloat(wEl.value) || 1; const
|
||||||
h = parseFloat(hEl.value) || 1;
|
h = Number.parseFloat(hEl.value) || 1;
|
||||||
if (canvasEl) {
|
if (canvasEl) {
|
||||||
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
|
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
|
||||||
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
|
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
|
||||||
@@ -109,7 +109,7 @@ function bindJaugeDefinitionSelect() {
|
|||||||
const champNomSel = document.getElementById('field-champ_nom');
|
const champNomSel = document.getElementById('field-champ_nom');
|
||||||
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
||||||
defSel.addEventListener('change', () => {
|
defSel.addEventListener('change', () => {
|
||||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
|
const info = DEFINITIONS_DATA?.[defSel.value] || { rows: [], fields: [] };
|
||||||
rowSel.innerHTML = info.rows.length
|
rowSel.innerHTML = info.rows.length
|
||||||
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
||||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
: '<option value="">Aucune donnée dans cet objet</option>';
|
||||||
@@ -149,9 +149,9 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
|||||||
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
|
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
|
||||||
if (!fieldSels.length) return;
|
if (!fieldSels.length) return;
|
||||||
defSel.addEventListener('change', () => {
|
defSel.addEventListener('change', () => {
|
||||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
||||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
||||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
const opts = `<option value="">(sélectionner un objet d'abord)</option>${fieldOptionsHtml}`;
|
||||||
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
|
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ function addClauseRow(key) {
|
|||||||
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
||||||
// déjà réglé plus haut dans le panneau).
|
// déjà réglé plus haut dans le panneau).
|
||||||
const defSel = document.getElementById('field-data_definition_id');
|
const defSel = document.getElementById('field-data_definition_id');
|
||||||
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
|
if (defSel?.value) defSel.dispatchEvent(new Event('change'));
|
||||||
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
||||||
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
||||||
initFilterValuePickers();
|
initFilterValuePickers();
|
||||||
@@ -227,9 +227,9 @@ function bindClauseListDefinitionSelect(defSelId) {
|
|||||||
const defSel = document.getElementById(defSelId);
|
const defSel = document.getElementById(defSelId);
|
||||||
if (!defSel) return;
|
if (!defSel) return;
|
||||||
defSel.addEventListener('change', () => {
|
defSel.addEventListener('change', () => {
|
||||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
||||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
||||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
const opts = `<option value="">(sélectionner un objet d'abord)</option>${fieldOptionsHtml}`;
|
||||||
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
|
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
|
||||||
const current = sel.value;
|
const current = sel.value;
|
||||||
sel.innerHTML = opts;
|
sel.innerHTML = opts;
|
||||||
@@ -251,14 +251,14 @@ function bindClauseListDefinitionSelect(defSelId) {
|
|||||||
// d'une expression s'il les rencontrait telles quelles).
|
// d'une expression s'il les rencontrait telles quelles).
|
||||||
const _FILTER_REF_OPEN = '{' + '{';
|
const _FILTER_REF_OPEN = '{' + '{';
|
||||||
const _FILTER_REF_CLOSE = '}' + '}';
|
const _FILTER_REF_CLOSE = '}' + '}';
|
||||||
const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; // NOSONAR S8786 - classes negatives, decoupage sans ambiguite, teste jusqu'a 100k car. sans blowup (voir CODE_QUALITY.md)
|
||||||
// Une paire d'accolades doublées entourant "$nom_variable" (voir
|
// Une paire d'accolades doublées entourant "$nom_variable" (voir
|
||||||
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
|
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
|
||||||
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
|
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
|
||||||
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
|
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
|
||||||
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
|
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
|
||||||
// variable, utile pour une variable "objet"/"tableau".
|
// variable, utile pour une variable "objet"/"tableau".
|
||||||
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md)
|
||||||
|
|
||||||
function _filterValueDefinitionId(objName) {
|
function _filterValueDefinitionId(objName) {
|
||||||
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
|
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
|
||||||
@@ -266,8 +266,9 @@ function _filterValueDefinitionId(objName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
||||||
const info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
const info = DEFINITIONS_DATA?.[defId] || { fields: [] };
|
||||||
fieldSel.innerHTML = `<option value="">— champ —</option>${info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('')}`;
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('');
|
||||||
|
fieldSel.innerHTML = `<option value="">— champ —</option>${fieldOptionsHtml}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFilterValueFromDynamic(wrap) {
|
function updateFilterValueFromDynamic(wrap) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
function _personnageElementId() {
|
function _personnageElementId() {
|
||||||
const body = document.getElementById('personnagePanelBody');
|
const body = document.getElementById('personnagePanelBody');
|
||||||
return body ? parseInt(body.dataset.elementId, 10) : null;
|
return body ? Number.parseInt(body.dataset.elementId, 10) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _savePersonnageData(data) {
|
function _savePersonnageData(data) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function _stopPersonnageIdlePreview(elementId) {
|
|||||||
|
|
||||||
function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||||
_stopPersonnageIdlePreview(elementId);
|
_stopPersonnageIdlePreview(elementId);
|
||||||
if (!frames || !frames.length) return;
|
if (!frames?.length) return;
|
||||||
targetEl.src = frames[0];
|
targetEl.src = frames[0];
|
||||||
if (frames.length === 1) return;
|
if (frames.length === 1) return;
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
@@ -42,7 +42,7 @@ function restartPersonnageIdlePreview(elementId, animations) {
|
|||||||
|
|
||||||
function startAllPersonnagePreviews() {
|
function startAllPersonnagePreviews() {
|
||||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach((targetEl) => {
|
document.querySelectorAll('#canvas [data-personnage="1"]').forEach((targetEl) => {
|
||||||
const elementId = parseInt(targetEl.dataset.elementId, 10);
|
const elementId = Number.parseInt(targetEl.dataset.elementId, 10);
|
||||||
const animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
const animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||||
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function restoreTreeCollapsedState() {
|
|||||||
document.querySelectorAll('.elementTree .treeNode').forEach((node) => {
|
document.querySelectorAll('.elementTree .treeNode').forEach((node) => {
|
||||||
const id = node.dataset.elementId;
|
const id = node.dataset.elementId;
|
||||||
let collapsed = false;
|
let collapsed = false;
|
||||||
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
|
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {} // NOSONAR S2486 - lecture localStorage optionnelle (etat replie/deplie), echec sans consequence, reste "deplie" par defaut
|
||||||
node.classList.toggle('collapsed', collapsed);
|
node.classList.toggle('collapsed', collapsed);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ function saveFloatPanelState(key, state) {
|
|||||||
try {
|
try {
|
||||||
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
||||||
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
|
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
|
||||||
} catch (e) {}
|
} catch (e) {} // NOSONAR S2486 - localStorage optionnel, voir CODE_QUALITY.md
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFloatPanelState(key) {
|
function loadFloatPanelState(key) {
|
||||||
@@ -123,7 +123,7 @@ if (!window.__forgeFloatPanelDragBound) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
saveFloatPanelState(key, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) });
|
saveFloatPanelState(key, { left: Number.parseFloat(panel.style.left), top: Number.parseFloat(panel.style.top) });
|
||||||
}
|
}
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
@@ -144,7 +144,7 @@ function addIconElement(iconClass) {
|
|||||||
form.method = 'post';
|
form.method = 'post';
|
||||||
form.action = ELEMENT_ADD_URL;
|
form.action = ELEMENT_ADD_URL;
|
||||||
form.innerHTML = '<input type="hidden" name="widget" value="icone">'
|
form.innerHTML = '<input type="hidden" name="widget" value="icone">'
|
||||||
+ `<input type="hidden" name="icon_class" value="${iconClass.replace(/"/g, '"')}">`;
|
+ `<input type="hidden" name="icon_class" value="${iconClass.replaceAll('"', '"')}">`;
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ if (!window.__forgeIconGalleryDragBound) {
|
|||||||
draggedIconClass = tile.dataset.iconClass;
|
draggedIconClass = tile.dataset.iconClass;
|
||||||
tile.classList.add('dragging');
|
tile.classList.add('dragging');
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {}
|
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {} // NOSONAR S2486 - setData() cosmetique HTML5 (validite du drag cross-navigateur) ; le drop lit draggedIconClass (variable JS), jamais dataTransfer.getData()
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('dragend', (e) => {
|
document.addEventListener('dragend', (e) => {
|
||||||
@@ -227,7 +227,7 @@ if (!window.__forgeTreeToggleBound) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const node = btn.closest('.treeNode');
|
const node = btn.closest('.treeNode');
|
||||||
const collapsed = node.classList.toggle('collapsed');
|
const collapsed = node.classList.toggle('collapsed');
|
||||||
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {}
|
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {} // NOSONAR S2486 - ecriture localStorage optionnelle (etat replie/deplie), echec sans consequence
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ if (!window.__forgeTreeDragBound) {
|
|||||||
treeDragParentId = info.parentId;
|
treeDragParentId = info.parentId;
|
||||||
row.classList.add('dragging');
|
row.classList.add('dragging');
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {}
|
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {} // NOSONAR S2486 - setData() cosmetique HTML5 ; le drop lit treeDragElementId (variable JS), jamais dataTransfer.getData()
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('dragend', (e) => {
|
document.addEventListener('dragend', (e) => {
|
||||||
@@ -314,7 +314,10 @@ if (!window.__forgeTreeDragBound) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
clearTreeDropIndicators();
|
clearTreeDropIndicators();
|
||||||
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
|
let zoneClass = 'dropInside';
|
||||||
|
if (zone === 'before') zoneClass = 'dropBefore';
|
||||||
|
else if (zone === 'after') zoneClass = 'dropAfter';
|
||||||
|
row.classList.add(zoneClass);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('drop', (e) => {
|
document.addEventListener('drop', (e) => {
|
||||||
@@ -456,10 +459,10 @@ function onElementMouseDown(e, id) {
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startLeft = parseFloat(el.style.left); const
|
const startLeft = Number.parseFloat(el.style.left); const
|
||||||
startTop = parseFloat(el.style.top);
|
startTop = Number.parseFloat(el.style.top);
|
||||||
const width = parseFloat(el.style.width); const
|
const width = Number.parseFloat(el.style.width); const
|
||||||
height = parseFloat(el.style.height);
|
height = Number.parseFloat(el.style.height);
|
||||||
let moved = false;
|
let moved = false;
|
||||||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
||||||
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
||||||
@@ -481,8 +484,8 @@ function onElementMouseDown(e, id) {
|
|||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
if (moved) {
|
if (moved) {
|
||||||
const newLeft = parseFloat(el.style.left); const
|
const newLeft = Number.parseFloat(el.style.left); const
|
||||||
newTop = parseFloat(el.style.top);
|
newTop = Number.parseFloat(el.style.top);
|
||||||
updatePosFields(newLeft, newTop, width, height);
|
updatePosFields(newLeft, newTop, width, height);
|
||||||
saveGeometry(id, newLeft, newTop, width, height);
|
saveGeometry(id, newLeft, newTop, width, height);
|
||||||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||||||
@@ -501,10 +504,10 @@ function onResizeMouseDown(e, id) {
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const startX = e.clientX; const
|
const startX = e.clientX; const
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
const startWidth = parseFloat(el.style.width); const
|
const startWidth = Number.parseFloat(el.style.width); const
|
||||||
startHeight = parseFloat(el.style.height);
|
startHeight = Number.parseFloat(el.style.height);
|
||||||
const left = parseFloat(el.style.left); const
|
const left = Number.parseFloat(el.style.left); const
|
||||||
top = parseFloat(el.style.top);
|
top = Number.parseFloat(el.style.top);
|
||||||
|
|
||||||
function onMove(ev) {
|
function onMove(ev) {
|
||||||
const dx = ev.clientX - startX; const
|
const dx = ev.clientX - startX; const
|
||||||
@@ -517,8 +520,8 @@ function onResizeMouseDown(e, id) {
|
|||||||
function onUp() {
|
function onUp() {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
const newWidth = parseFloat(el.style.width); const
|
const newWidth = Number.parseFloat(el.style.width); const
|
||||||
newHeight = parseFloat(el.style.height);
|
newHeight = Number.parseFloat(el.style.height);
|
||||||
updatePosFields(left, top, newWidth, newHeight);
|
updatePosFields(left, top, newWidth, newHeight);
|
||||||
saveGeometry(id, left, top, newWidth, newHeight);
|
saveGeometry(id, left, top, newWidth, newHeight);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,16 @@
|
|||||||
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
||||||
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
||||||
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
|
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
|
||||||
const SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
|
// `let`, pas `const` : réassignée depuis un AUTRE <script> (voir
|
||||||
|
// refreshSceneObjectNames(), static/js/scenes/scene-editor.js) — deux
|
||||||
|
// balises <script> classiques sur la même page partagent un même
|
||||||
|
// environnement lexical global, mais une liaison `const` posée dans l'une
|
||||||
|
// ne peut PAS être réassignée depuis l'autre (TypeError: Assignment to
|
||||||
|
// constant variable., vérifié empiriquement). Bug réel trouvé lors de
|
||||||
|
// l'audit S2703 : renommer un personnage puis ouvrir un dialogue de
|
||||||
|
// déclencheur SANS recharger la page ne voyait jamais le nouveau nom.
|
||||||
|
// eslint-disable-next-line prefer-const -- reassignee depuis scene-editor.js, voir commentaire ci-dessus
|
||||||
|
let SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
|
||||||
|
|
||||||
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
|
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
|
||||||
// .questBubble-pnj, static/style.css) même si le nom affiché est
|
// .questBubble-pnj, static/style.css) même si le nom affiché est
|
||||||
@@ -34,12 +43,12 @@ function triggerBubbleKind(speaker) {
|
|||||||
// valeur déjà enregistrée.
|
// valeur déjà enregistrée.
|
||||||
function triggerSpeakerOptions(currentSpeaker) {
|
function triggerSpeakerOptions(currentSpeaker) {
|
||||||
const options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
const options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
||||||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
if (currentSpeaker && !options.includes(currentSpeaker)) options.push(currentSpeaker);
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerEscAttr(str) {
|
function triggerEscAttr(str) {
|
||||||
return (str || '').replace(/"/g, '"');
|
return (str || '').replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
|
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
|
||||||
@@ -73,11 +82,17 @@ function triggerAudioAssetOptions() {
|
|||||||
return (typeof USER_ASSETS_OPTIONS !== 'undefined' ? USER_ASSETS_OPTIONS : []).filter((a) => a.kind === 'audio');
|
return (typeof USER_ASSETS_OPTIONS !== 'undefined' ? USER_ASSETS_OPTIONS : []).filter((a) => a.kind === 'audio');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerAudioOptionHtml(asset, audioUrl) {
|
||||||
|
const selected = asset.url === audioUrl ? ' selected' : '';
|
||||||
|
return `<option value="${asset.url.replaceAll('"', '"')}"${selected}>${asset.name.replaceAll('<', '<')}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
||||||
const assetOptions = triggerAudioAssetOptions();
|
const assetOptions = triggerAudioAssetOptions();
|
||||||
const selectOptions = `<option value="">Aucune voix off</option>${assetOptions.map((a) => `<option value="${a.url.replace(/"/g, '"')}"${a.url === audioUrl ? ' selected' : ''}>${a.name.replace(/</g, '<')}</option>`).join('')}`;
|
const optionsHtml = assetOptions.map((a) => triggerAudioOptionHtml(a, audioUrl)).join('');
|
||||||
|
const selectOptions = `<option value="">Aucune voix off</option>${optionsHtml}`;
|
||||||
const preview = audioUrl
|
const preview = audioUrl
|
||||||
? `<audio controls preload="none" src="${audioUrl.replace(/"/g, '"')}" class="questBubbleAudioPreview"></audio>`
|
? `<audio controls preload="none" src="${audioUrl.replaceAll('"', '"')}" class="questBubbleAudioPreview"></audio>`
|
||||||
: '';
|
: '';
|
||||||
return (
|
return (
|
||||||
'<div class="questBubbleAudioRow">'
|
'<div class="questBubbleAudioRow">'
|
||||||
@@ -92,7 +107,7 @@ function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
|||||||
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
||||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||||
const kind = triggerBubbleKind(line.speaker);
|
const kind = triggerBubbleKind(line.speaker);
|
||||||
const speakerSelect = triggerSpeakerOptions(line.speaker).map((name) => `<option value="${name.replace(/"/g, '"')}"${name === line.speaker ? ' selected' : ''}>${name === 'Joueur' ? 'Joueur' : name}</option>`).join('');
|
const speakerSelect = triggerSpeakerOptions(line.speaker).map((name) => `<option value="${name.replaceAll('"', '"')}"${name === line.speaker ? ' selected' : ''}>${name === 'Joueur' ? 'Joueur' : name}</option>`).join('');
|
||||||
return (
|
return (
|
||||||
'<div class="questBubbleRow">'
|
'<div class="questBubbleRow">'
|
||||||
+ `<div class="questBubble questBubble-${kind}">`
|
+ `<div class="questBubble questBubble-${kind}">`
|
||||||
@@ -103,7 +118,7 @@ function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
|||||||
+ '<div class="questBubbleBody">'
|
+ '<div class="questBubbleBody">'
|
||||||
+ '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." '
|
+ '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." '
|
||||||
+ 'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" '
|
+ 'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" '
|
||||||
+ `onchange="triggerInlineUpdateBubbleText('${dialogueId}', ${index}, this.value)">${line.text.replace(/</g, '<')}</textarea>${
|
+ `onchange="triggerInlineUpdateBubbleText('${dialogueId}', ${index}, this.value)">${line.text.replaceAll('<', '<')}</textarea>${
|
||||||
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url)
|
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url)
|
||||||
}</div>`
|
}</div>`
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -159,7 +174,7 @@ function triggerQuestionBubbleHtml(dialogueId, line, index) {
|
|||||||
function triggerVariableActionLabel(item) {
|
function triggerVariableActionLabel(item) {
|
||||||
const opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
|
const opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
|
||||||
const valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ` ${item.data_value}` : '';
|
const valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ` ${item.data_value}` : '';
|
||||||
return `${(item.target_variable || '').replace(/</g, '<')} — ${opLabel.replace(/</g, '<')}${valuePart}`;
|
return `${(item.target_variable || '').replaceAll('<', '<')} — ${opLabel.replaceAll('<', '<')}${valuePart}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feuille "Modifier une variable" — un encart compact (même largeur que
|
// Feuille "Modifier une variable" — un encart compact (même largeur que
|
||||||
@@ -192,7 +207,7 @@ const COLLISION_LEAF_PILL_LABELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function triggerItemLeafId(item) {
|
function triggerItemLeafId(item) {
|
||||||
return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id;
|
return item.type === 'dialogue' ? item.dialogue?.id : item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
|
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
|
||||||
@@ -255,7 +270,7 @@ function triggerLeafPillHtml(item, target, targetId, index, total) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerAssetFilename(url) {
|
function triggerAssetFilename(url) {
|
||||||
return ((url || '').split('/').pop() || url || '').replace(/</g, '<');
|
return ((url || '').split('/').pop() || url || '').replaceAll('<', '<');
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerGenericLeafBoxHtml(title, text) {
|
function triggerGenericLeafBoxHtml(title, text) {
|
||||||
@@ -286,7 +301,7 @@ function triggerLeafBoxHtml(item) {
|
|||||||
return triggerGenericLeafBoxHtml('Vidéo', `${triggerAssetFilename(item.asset_url)} — ${mLabel}`);
|
return triggerGenericLeafBoxHtml('Vidéo', `${triggerAssetFilename(item.asset_url)} — ${mLabel}`);
|
||||||
}
|
}
|
||||||
if (item.type === 'indication') {
|
if (item.type === 'indication') {
|
||||||
return triggerGenericLeafBoxHtml('Indication', `Objet #${item.object_id} — ${(item.texte || '').replace(/</g, '<')}`);
|
return triggerGenericLeafBoxHtml('Indication', `Objet #${item.object_id} — ${(item.texte || '').replaceAll('<', '<')}`);
|
||||||
}
|
}
|
||||||
if (item.type === 'attendre') {
|
if (item.type === 'attendre') {
|
||||||
return triggerGenericLeafBoxHtml('Attendre', `${item.data_value || '0'} seconde(s)`);
|
return triggerGenericLeafBoxHtml('Attendre', `${item.data_value || '0'} seconde(s)`);
|
||||||
@@ -319,7 +334,7 @@ function triggerAppendButtonHtml(target, targetId, afterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerActionChainHtml(chain, target, targetId) {
|
function triggerActionChainHtml(chain, target, targetId) {
|
||||||
if (!chain || !chain.length) return '';
|
if (!chain?.length) return '';
|
||||||
return chain.map((item, i) => {
|
return chain.map((item, i) => {
|
||||||
const leafId = triggerItemLeafId(item);
|
const leafId = triggerItemLeafId(item);
|
||||||
return (
|
return (
|
||||||
@@ -374,14 +389,14 @@ const _triggerCollapsed = {};
|
|||||||
let _triggerActiveDialogueId = null;
|
let _triggerActiveDialogueId = null;
|
||||||
|
|
||||||
function _chainLeafIds(chain) {
|
function _chainLeafIds(chain) {
|
||||||
return (chain || []).map((item) => (item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id)).filter(Boolean);
|
return (chain || []).map((item) => (item.type === 'dialogue' ? item.dialogue?.id : item.id)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _triggerLeafIds(trigger) {
|
function _triggerLeafIds(trigger) {
|
||||||
if (trigger.condition) {
|
if (trigger.condition) {
|
||||||
const vrai = trigger.condition.si_vrai; const
|
const vrai = trigger.condition.si_vrai; const
|
||||||
faux = trigger.condition.si_faux;
|
faux = trigger.condition.si_faux;
|
||||||
return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain));
|
return _chainLeafIds(vrai?.chain).concat(_chainLeafIds(faux?.chain));
|
||||||
}
|
}
|
||||||
return _chainLeafIds(trigger.chain);
|
return _chainLeafIds(trigger.chain);
|
||||||
}
|
}
|
||||||
@@ -389,10 +404,11 @@ function _triggerLeafIds(trigger) {
|
|||||||
function triggerChainHtml(trigger) {
|
function triggerChainHtml(trigger) {
|
||||||
const isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
const isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
||||||
const leafIds = _triggerLeafIds(trigger);
|
const leafIds = _triggerLeafIds(trigger);
|
||||||
const isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
|
const isActive = _triggerActiveDialogueId && leafIds.includes(_triggerActiveDialogueId);
|
||||||
const cardOnclick = `triggerClickObjectCard(${trigger.object_id}, ${leafIds[0] ? `'${leafIds[0]}'` : 'null'})`;
|
const firstLeafArg = leafIds[0] ? `'${leafIds[0]}'` : 'null';
|
||||||
|
const cardOnclick = `triggerClickObjectCard(${trigger.object_id}, ${firstLeafArg})`;
|
||||||
const card = `<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="${cardOnclick}">${collisionRuleThumbHtml(trigger)
|
const card = `<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="${cardOnclick}">${collisionRuleThumbHtml(trigger)
|
||||||
}<span class="questTriggerObjectName">${(trigger.object_name || '').replace(/</g, '<')}</span>`
|
}<span class="questTriggerObjectName">${(trigger.object_name || '').replaceAll('<', '<')}</span>`
|
||||||
+ '<span class="questTriggerCollapseChevron">▾</span>'
|
+ '<span class="questTriggerCollapseChevron">▾</span>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
@@ -456,7 +472,7 @@ function triggerCondArrowHtml(kind, label) {
|
|||||||
|
|
||||||
function triggerConditionColumnHtml(condition, target, targetId) {
|
function triggerConditionColumnHtml(condition, target, targetId) {
|
||||||
const opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
const opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
||||||
const label = `${(condition.variable || '').replace(/</g, '<')} ${(opLabel || '').replace(/</g, '<')} ${(condition.valeur || '').replace(/</g, '<')}`;
|
const label = `${(condition.variable || '').replaceAll('<', '<')} ${(opLabel || '').replaceAll('<', '<')} ${(condition.valeur || '').replaceAll('<', '<')}`;
|
||||||
const branchesHtml = ['si_vrai', 'si_faux'].map((branchKey) => {
|
const branchesHtml = ['si_vrai', 'si_faux'].map((branchKey) => {
|
||||||
const kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
const kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
||||||
const arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
const arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||||
@@ -511,7 +527,7 @@ function _registerChainDialogues(chain, owner, dialogueIds) {
|
|||||||
// l'autre.
|
// l'autre.
|
||||||
function _clearRegistryEntriesWithKey(key) {
|
function _clearRegistryEntriesWithKey(key) {
|
||||||
Object.keys(_triggerDialogueRegistry).forEach((id) => {
|
Object.keys(_triggerDialogueRegistry).forEach((id) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
|
if (Object.hasOwn(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,8 +557,8 @@ function triggerLoadAll() {
|
|||||||
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
|
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
|
||||||
// garde (cliquer sur une autre carte reste possible, voir
|
// garde (cliquer sur une autre carte reste possible, voir
|
||||||
// triggerClickObjectCard).
|
// triggerClickObjectCard).
|
||||||
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
|
if (!dialogueIds.includes(_triggerActiveDialogueId)) {
|
||||||
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
|
_triggerActiveDialogueId = dialogueIds.at(-1) || null;
|
||||||
}
|
}
|
||||||
triggerRenderSlot();
|
triggerRenderSlot();
|
||||||
});
|
});
|
||||||
@@ -626,11 +642,14 @@ function triggerInlineAddQuestion(dialogueId) {
|
|||||||
triggerInlineSaveDialogue(dialogueId);
|
triggerInlineSaveDialogue(dialogueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Même effet que triggerInlineUpdateBubbleText ci-dessous (mettre à jour
|
||||||
|
// `.text` d'une ligne puis sauvegarder) — deux noms distincts car
|
||||||
|
// référencés séparément dans le HTML généré (onchange de la question vs.
|
||||||
|
// onchange de la bulle de réplique, voir triggerQuestionBubbleHtml/
|
||||||
|
// triggerDialogueBubbleHtml), mais un seul corps (S4144 : déléguer plutôt
|
||||||
|
// que dupliquer la logique).
|
||||||
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
|
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
|
||||||
const entry = _triggerDialogueRegistry[dialogueId];
|
triggerInlineUpdateBubbleText(dialogueId, index, text);
|
||||||
if (!entry) return;
|
|
||||||
entry.dialogueRef.lines[index].text = text;
|
|
||||||
triggerInlineSaveDialogue(dialogueId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
|
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
|
||||||
@@ -642,7 +661,7 @@ function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
|
|||||||
const entry = _triggerDialogueRegistry[dialogueId];
|
const entry = _triggerDialogueRegistry[dialogueId];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const line = entry.dialogueRef.lines[index];
|
const line = entry.dialogueRef.lines[index];
|
||||||
const count = Math.max(2, Math.min(4, parseInt(value, 10) || 2));
|
const count = Math.max(2, Math.min(4, Number.parseInt(value, 10) || 2));
|
||||||
while (line.choices.length < count) line.choices.push('');
|
while (line.choices.length < count) line.choices.push('');
|
||||||
while (line.choices.length > count) line.choices.pop();
|
while (line.choices.length > count) line.choices.pop();
|
||||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||||
@@ -667,7 +686,7 @@ function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) {
|
|||||||
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
|
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
|
||||||
const entry = _triggerDialogueRegistry[dialogueId];
|
const entry = _triggerDialogueRegistry[dialogueId];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
entry.dialogueRef.lines[index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
|
entry.dialogueRef.lines[index].reward_amount = Math.max(0, Number.parseInt(value, 10) || 0);
|
||||||
triggerInlineSaveDialogue(dialogueId);
|
triggerInlineSaveDialogue(dialogueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,7 +765,7 @@ function triggerRenderObjectStep() {
|
|||||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||||
grid.innerHTML = objects.map((o, i) => `<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:${i * 40}ms" onclick="triggerSelectObject(${o.id})">`
|
grid.innerHTML = objects.map((o, i) => `<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:${i * 40}ms" onclick="triggerSelectObject(${o.id})">`
|
||||||
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
+ `<div class="questTriggerObjectThumb">${collisionRuleThumbHtml(o)}</div>`
|
||||||
+ `<span>${(o.name || '').replace(/</g, '<')}</span>`
|
+ `<span>${(o.name || '').replaceAll('<', '<')}</span>`
|
||||||
+ '</button>').join('');
|
+ '</button>').join('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
var fieldsList = document.getElementById('fieldsList');
|
var fieldsList = document.getElementById('fieldsList');
|
||||||
var template = document.getElementById('fieldRowTemplate');
|
var template = document.getElementById('fieldRowTemplate');
|
||||||
var btnAddField = document.getElementById('btnAddField');
|
var btnAddField = document.getElementById('btnAddField');
|
||||||
var NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal'];
|
var NUMERIC_FIELD_TYPES = new Set(['nombre_entier', 'nombre_decimal']);
|
||||||
|
|
||||||
|
function toggle(target, placeholder, visible) {
|
||||||
|
target.style.display = visible ? '' : 'none';
|
||||||
|
placeholder.style.display = visible ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function wireRow(row) {
|
function wireRow(row) {
|
||||||
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
|
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
|
||||||
@@ -29,14 +34,9 @@
|
|||||||
var boundsTargets = row.querySelectorAll('.boundsTarget');
|
var boundsTargets = row.querySelectorAll('.boundsTarget');
|
||||||
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
|
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
|
||||||
|
|
||||||
function toggle(target, placeholder, visible) {
|
|
||||||
target.style.display = visible ? '' : 'none';
|
|
||||||
placeholder.style.display = visible ? 'none' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncFieldTypeVisibility() {
|
function syncFieldTypeVisibility() {
|
||||||
var isRelation = typeSelect.value === 'relation';
|
var isRelation = typeSelect.value === 'relation';
|
||||||
var isNumeric = NUMERIC_FIELD_TYPES.includes(typeSelect.value);
|
var isNumeric = NUMERIC_FIELD_TYPES.has(typeSelect.value);
|
||||||
toggle(relationTarget, relationPlaceholder, isRelation);
|
toggle(relationTarget, relationPlaceholder, isRelation);
|
||||||
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
|
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
<div class="notification is-danger">{{ error }}</div>
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# NOSONAR S5247 - qr_svg est produit par la librairie qrcode a partir d'une URI TOTP generee serveur, aucun texte libre utilisateur embarque #}
|
<div class="totpQrWrap">{{ qr_svg|safe }}</div> {# //NOSONAR S5247 - qr_svg est produit par la librairie qrcode a partir d'une URI TOTP generee serveur, aucun texte libre utilisateur embarque #}
|
||||||
<div class="totpQrWrap">{{ qr_svg|safe }}</div>
|
|
||||||
<p class="hint">Impossible de scanner ? Saisis cette clé manuellement dans l'application : <code>{{ secret }}</code></p>
|
<p class="hint">Impossible de scanner ? Saisis cette clé manuellement dans l'application : <code>{{ secret }}</code></p>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('register_2fa') }}" class="mt-4">
|
<form method="post" action="{{ url_for('register_2fa') }}" class="mt-4">
|
||||||
|
|||||||
@@ -22,8 +22,7 @@
|
|||||||
<span class="onboardingCardLabel">{{ info.label }}</span>
|
<span class="onboardingCardLabel">{{ info.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="onboardingCardFace onboardingCardBack">
|
<span class="onboardingCardFace onboardingCardBack">
|
||||||
{# NOSONAR S5247 - description est du HTML fixe, code en dur dans db.ONBOARDING_TYPES (aucune saisie utilisateur) #}
|
<span class="onboardingCardDesc">{{ info.description|safe }}</span> {# //NOSONAR S5247 - description est du HTML fixe, code en dur dans db.ONBOARDING_TYPES (aucune saisie utilisateur) #}
|
||||||
<span class="onboardingCardDesc">{{ info.description|safe }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@
|
|||||||
// routes/play/game_play.py) que la partie publique par joueur
|
// routes/play/game_play.py) que la partie publique par joueur
|
||||||
// (/jouer/<slug>, routes/public_play/), deux familles de routes
|
// (/jouer/<slug>, routes/public_play/), deux familles de routes
|
||||||
// distinctes (voir état par joueur, Phase 1).
|
// distinctes (voir état par joueur, Phase 1).
|
||||||
let gameData = {{ payload_json|safe }};
|
let gameData = {{ payload_json|safe }}; // NOSONAR S5247 - json_for_script() echappe deja la balise fermante de script (voir db/json_for_script.py)
|
||||||
let screensData = gameData.screens;
|
let screensData = gameData.screens;
|
||||||
{% if offline_mode %}
|
{% if offline_mode %}
|
||||||
// Export Web/SCORM (voir publish/build_scorm_package.py) : paquet
|
// Export Web/SCORM (voir publish/build_scorm_package.py) : paquet
|
||||||
|
|||||||
@@ -307,8 +307,7 @@
|
|||||||
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }} {{ 'isQuizFullscreenPreview' if o.quiz_fullscreen else '' }}" id="el-{{ o.id }}"
|
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }} {{ 'isQuizFullscreenPreview' if o.quiz_fullscreen else '' }}" id="el-{{ o.id }}"
|
||||||
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
|
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
|
||||||
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||||
{# NOSONAR S5247 - rendered_html vient de render_scene_object() (Python), qui echappe deja tout texte utilisateur via html.escape() #}
|
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div> {# //NOSONAR S5247 - rendered_html vient de render_scene_object() (Python), qui echappe deja tout texte utilisateur via html.escape() #}
|
||||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
|
||||||
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
||||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
||||||
|
|||||||
Reference in New Issue
Block a user