.valid_write{
visibility:hidden;
opacity:0;
transition:0.3s;
}
.tkd-trace-game{
position:relative;
width:340px;
margin:20px auto;
font-family:Arial, sans-serif;
text-align:center;
max-height:370px;
}
.svg-container{
position:absolute;
inset:0;
display:flex;
align-items:center;
justify-content:center;
pointer-events:none;
user-select:none;
height:340px;
z-index:3;
}
.svg-container img{
width:240px;
opacity:0.35;
}
#fillCanvas{
position:absolute;
top:0;
left:0;
width:340px;
/* ⚠️ Doit rester égal à la résolution interne du canvas (340x340, voir
fillCanvas.width/height plus bas) et à la hauteur de .svg-container.
Une valeur différente ici étire verticalement tout ce qui est dessiné
sur le canvas par rapport au modèle gris, ce qui donnait l'impression
d'un tracé "décalé vers le bas". */
height:340px;
z-index:2;
pointer-events:none;
box-sizing:border-box;
}
#traceCanvas{
position:relative;
z-index:1;
width:340px;
/* Même remarque que #fillCanvas ci-dessus : doit rester 340px. */
height:340px;
border:3px solid #38656d;
border-radius:15px;
background:#FCFCFC;
touch-action:none;
box-sizing:border-box;
}
.controls{
margin-top:15px;
position:absolute;
bottom:10px;
right:10px;
z-index:10;
}
#resetBtn{
font-size:16px;
padding:12px;
border:none;
border-radius:10px;
background:#41828d;
color:white;
cursor:pointer;
font-family:"Kalam";
font-weight:bold;
}
#message{
margin-top:20px;
font-size:32px;
font-weight:bold;
min-height:40px;
position: absolute;
top: 0;
z-index: 2;
}
(function () {
'use strict';
// =========================
// CONFIG
// =========================
// ⚠️ Les SVG sont maintenant chargés depuis app.taekwon-book.com (même origine que
// cette page), et non plus depuis taekwon-book.com. Avant, le navigateur "taintait"
// (contaminait) les canvas dès qu'on y dessinait l'image du modèle, car
// taekwon-book.com ne renvoie pas d'en-tête CORS pour ces fichiers : toute lecture
// de pixels (hiddenCtx.getImageData / toleranceCtx.getImageData, utilisées par la
// validation) provoquait alors une erreur de sécurité silencieuse, et le jeu ne
// validait donc jamais un caractère, même bien tracé. Les sons restent sur
// taekwon-book.com (l'audio n'est pas concerné par cette restriction).
const letters = [
{
svg: 'https://app.taekwon-book.com/wp-content/plugins/taekon-app/assets/images/jeux/ecriture/hangeul/taekwon_icon_tae.svg',
sound: 'https://taekwon-book.com/wp-content/themes/hello-elementor-child/app/sons/ecriture/taekwon_son_tae.mp3'
},
{
svg: 'https://app.taekwon-book.com/wp-content/plugins/taekon-app/assets/images/jeux/ecriture/hangeul/taekwon_icon_kwon.svg',
sound: 'https://taekwon-book.com/wp-content/themes/hello-elementor-child/app/sons/ecriture/taekwon_son_kwon.mp3'
},
{
svg: 'https://app.taekwon-book.com/wp-content/plugins/taekon-app/assets/images/jeux/ecriture/hangeul/taekwon_icon_do.svg',
sound: 'https://taekwon-book.com/wp-content/themes/hello-elementor-child/app/sons/ecriture/taekwon_son_do.mp3'
}
];
const validationSound =
'https://taekwon-book.com/wp-content/themes/hello-elementor-child/app/sons/general/son_juste.mp3';
const finalSound =
'https://taekwon-book.com/wp-content/themes/hello-elementor-child/app/sons/general/son_yaay.mp3';
const drawingSound = new Audio(
'https://taekwon-book.com/wp-content/themes/hello-elementor-child/app/sons/ecriture/crayon.mp3'
);
drawingSound.loop = true;
drawingSound.volume = 0.35;
// -------------------------------------------------------------------------
// RÉGLAGES DE VALIDATION (à ajuster ici si besoin, après avoir testé avec
// des vrais enfants) :
//
// - thresholds[i] : % minimum du caractère qui doit être recouvert
// pour valider (0 à 1). Un par caractère.
// - PRECISION_THRESHOLD : % minimum du tracé de l'utilisateur qui doit
// tomber près du caractère (pas de gribouillage
// très en dehors des lignes). 0 à 1.
// - TOLERANCE_BLUR_PX : marge de tolérance autour du caractère (en
// pixels) pour la vérification de précision.
// Plus grand = plus permissif.
// - TOLERANCE_ALPHA_CUTOFF: à combien de "flou" on considère qu'un pixel
// est encore "proche" du caractère (0 à 255).
// Plus petit = plus permissif.
// -------------------------------------------------------------------------
const thresholds = [0.9, 0.9, 0.93];
const PRECISION_THRESHOLD = 0.55;
const TOLERANCE_BLUR_PX = 14;
const TOLERANCE_ALPHA_CUTOFF = 40;
// =========================
// VARIABLES
// =========================
let currentLetter = 0;
let drawing = false;
let validated = false;
let lastPos = null;
const svgModel = document.getElementById('svgModel');
const message = document.getElementById('message');
const canvas = document.getElementById('traceCanvas');
const ctx = canvas.getContext('2d');
canvas.width = 340;
canvas.height = 340;
ctx.lineWidth = 20;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#2c4a50';
// Calque visible qui se colore en vert là où le caractère est bien tracé
const fillCanvas = document.getElementById('fillCanvas');
const fillCtx = fillCanvas.getContext('2d');
fillCanvas.width = 340;
fillCanvas.height = 340;
// Canvas caché : silhouette pleine du modèle (sert au calcul de couverture)
const hiddenCanvas = document.createElement('canvas');
const hiddenCtx = hiddenCanvas.getContext('2d');
hiddenCanvas.width = 340;
hiddenCanvas.height = 340;
// Canvas caché : silhouette "floutée" du modèle (sert au calcul de précision,
// c'est-à-dire à détecter si le tracé de l'utilisateur reste raisonnablement
// proche du caractère plutôt que de gribouiller n'importe où)
const toleranceCanvas = document.createElement('canvas');
const toleranceCtx = toleranceCanvas.getContext('2d');
toleranceCanvas.width = 340;
toleranceCanvas.height = 340;
// =========================
// INITIALISATION
// =========================
loadCurrentLetter();
function loadCurrentLetter(){
svgModel.src = letters[currentLetter].svg;
message.innerHTML = '';
validated = false;
clearCanvas();
// Préparer les canvas de référence après chargement du SVG
svgModel.onload = () => {
// Silhouette pleine (couverture)
hiddenCtx.clearRect(0, 0, 340, 340);
hiddenCtx.drawImage(svgModel, 50, 50, 240, 240);
// Silhouette floutée (tolérance / précision)
toleranceCtx.clearRect(0, 0, 340, 340);
toleranceCtx.save();
toleranceCtx.filter = `blur(${TOLERANCE_BLUR_PX}px)`;
toleranceCtx.drawImage(svgModel, 50, 50, 240, 240);
toleranceCtx.restore();
};
}
// =========================
// DESSIN
// =========================
canvas.addEventListener('pointerdown', startDrawing);
canvas.addEventListener('pointermove', draw);
canvas.addEventListener('pointerup', stopDrawing);
canvas.addEventListener('pointercancel', stopDrawing);
// ✅ Pas d'écouteur "pointerleave" : avant, le tracé s'interrompait dès que
// la souris/le doigt sortait ne serait-ce qu'un instant du canevas (très
// gênant près des bords). Avec setPointerCapture() ci-dessous, le canevas
// continue de recevoir les événements même quand le pointeur en sort
// légèrement ; le tracé ne s'arrête plus que quand l'utilisateur relâche
// réellement (pointerup / pointercancel).
function getPosition(e){
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height)
};
}
function startDrawing(e){
if(validated) return;
canvas.setPointerCapture(e.pointerId);
drawing = true;
const pos = getPosition(e);
lastPos = pos;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
drawingSound.currentTime = 0;
drawingSound.play().catch(() => {});
}
function draw(e){
if(!drawing || validated) return;
const pos = getPosition(e);
// ✅ Lissage du trait : on passe par le point médian plutôt que de
// tracer des segments droits bruts (ligne beaucoup moins "hachée",
// surtout perceptible sur les tracés rapides au doigt).
const mid = { x: (lastPos.x + pos.x) / 2, y: (lastPos.y + pos.y) / 2 };
ctx.quadraticCurveTo(lastPos.x, lastPos.y, mid.x, mid.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(mid.x, mid.y);
paintFill(lastPos, mid);
lastPos = pos;
checkProgress();
}
function stopDrawing(e){
if (!drawing) return;
drawing = false;
drawingSound.pause();
if (e && e.pointerId != null && canvas.hasPointerCapture?.(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId);
}
}
// =========================
// COLORIAGE DU CARACTÈRE
// =========================
// Reproduit l'effet "le caractère se colore au fur et à mesure qu'on le
// trace correctement" (comme les jeux d'écriture type Duolingo). On dessine
// le même trait que l'utilisateur, en vert, puis on ne garde que la partie
// qui tombe à l'intérieur du caractère (destination-in) : impossible de
// "peindre en dehors des lignes".
function paintFill(from, to){
fillCtx.globalCompositeOperation = 'source-over';
fillCtx.strokeStyle = '#43a047';
fillCtx.lineWidth = ctx.lineWidth + 4;
fillCtx.lineCap = 'round';
fillCtx.lineJoin = 'round';
fillCtx.beginPath();
fillCtx.moveTo(from.x, from.y);
fillCtx.lineTo(to.x, to.y);
fillCtx.stroke();
fillCtx.globalCompositeOperation = 'destination-in';
fillCtx.drawImage(svgModel, 50, 50, 240, 240);
fillCtx.globalCompositeOperation = 'source-over';
}
// =========================
// VALIDATION PAR COUVERTURE + PRÉCISION
// =========================
function checkProgress(){
if(validated) return;
const modelData = hiddenCtx.getImageData(0, 0, 340, 340).data;
const userData = ctx.getImageData(0, 0, 340, 340).data;
const toleranceData = toleranceCtx.getImageData(0, 0, 340, 340).data;
let totalModelPixels = 0;
let matchedPixels = 0; // pixels du modèle recouverts -> couverture
let userPixels = 0; // pixels dessinés par l'utilisateur
let userPixelsClose = 0; // ...parmi eux, ceux proches du caractère -> précision
for(let i = 0; i 30){
totalModelPixels++;
if(userAlpha > 25) matchedPixels++;
}
// pixel dessiné par l'utilisateur
if(userAlpha > 25){
userPixels++;
if(toleranceData[i + 3] > TOLERANCE_ALPHA_CUTOFF) userPixelsClose++;
}
}
const coverage = totalModelPixels ? matchedPixels / totalModelPixels : 0;
const precision = userPixels ? userPixelsClose / userPixels : 1;
// DEBUG
// console.log('couverture', Math.round(coverage * 100) + '%', 'précision', Math.round(precision * 100) + '%');
// Seuil validation : il faut à la fois avoir recouvert assez du
// caractère ET être resté raisonnablement dans les lignes.
if (coverage > thresholds[currentLetter] && precision > PRECISION_THRESHOLD) {
validated = true;
validateLetter();
}
}
// =========================
// VALIDATION
// =========================
function validateLetter(){
drawingSound.pause();
// son validation
const validateAudio = new Audio(validationSound);
validateAudio.play().catch(() => {});
message.innerHTML = '⭐ Bravo ! ⭐';
// son symbole
setTimeout(() => {
const symbolAudio = new Audio(letters[currentLetter].sound);
symbolAudio.play().catch(() => {});
}, 600);
// prochain symbole
setTimeout(() => {
nextLetter();
}, 1800);
}
// =========================
// SUIVANT
// =========================
function nextLetter(){
currentLetter++;
if(currentLetter >= letters.length){
endGame();
return;
}
loadCurrentLetter();
}
// =========================
// FIN
// =========================
function endGame(){
svgModel.style.display = 'none';
clearCanvas();
message.innerHTML = '

Félicitations !
'
launchConfetti();
setTimeout(() => {
const finalAudio = new Audio(finalSound);
finalAudio.play().catch(() => {});
}, 150);
// Afficher le bouton final
const finalBtn = document.querySelector('.valid_write');
if(finalBtn){
finalBtn.style.visibility = 'visible';
finalBtn.style.opacity = '1';
}
// Faire apparaître (scroller vers) le bloc de fin, comme sur les autres
// pages de techniques (ex. jeu des paires) qui utilisent le même bloc
// partagé #bloc_question_fin. Ce bloc est une section Elementor commune,
// pas générée par ce script : il doit exister sur la page pour que ce
// scroll ait un effet visible.
setTimeout(() => {
const sectionFin = document.getElementById('bloc_question_fin');
if(sectionFin){
sectionFin.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 30);
}
// =========================
// RESET
// =========================
document.getElementById('resetBtn')
.addEventListener('click', clearCanvas);
function clearCanvas(){
ctx.clearRect(0, 0, canvas.width, canvas.height);
fillCtx.clearRect(0, 0, fillCanvas.width, fillCanvas.height);
}
// =========================
// CONFETTIS
// =========================
function launchConfetti(){
const duration = 2500;
const end = Date.now() + duration;
const interval = setInterval(() => {
if(Date.now() > end){
clearInterval(interval);
return;
}
createConfetti();
}, 120);
}
function createConfetti(){
const confetti = document.createElement('div');
const symbols = ['⭐','🥋','✨'];
confetti.innerHTML =
symbols[Math.floor(Math.random() * symbols.length)];
confetti.style.position = 'fixed';
confetti.style.left = Math.random() * window.innerWidth + 'px';
confetti.style.top = '-40px';
confetti.style.fontSize = (20 + Math.random() * 20) + 'px';
confetti.style.pointerEvents = 'none';
confetti.style.zIndex = 9999;
document.body.appendChild(confetti);
let pos = -40;
const fall = setInterval(() => {
pos += 6;
confetti.style.top = pos + 'px';
if(pos > window.innerHeight){
clearInterval(fall);
confetti.remove();
}
}, 16);
}
})();