Write Taekwon-Do

Taekwondo symbol
.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); } })();

Learn how to write Taekwon-Do in Korean! Follow the lines to complete each symbol.

#confetti-wrapper { position: absolute; width: 100%; height: 100vh; /* tu peux ajuster ou mettre 100vh */ overflow: hidden; } #confettiCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } (function () { const canvas = document.getElementById('confettiCanvas'); const ctx = canvas.getContext('2d'); function resizeCanvas() { const wrapper = document.getElementById('confetti-wrapper'); canvas.width = wrapper.offsetWidth; canvas.height = wrapper.offsetHeight; } window.addEventListener('resize', resizeCanvas); resizeCanvas(); let confetti = []; function createConfetti() { confetti = []; for (let i = 0; i { ctx.fillStyle = c.color; ctx.fillRect(c.x, c.y, c.size, c.size * 0.6); c.y += c.speed; c.x += c.drift; if (c.y > canvas.height) { c.y = -c.size; c.x = Math.random() * canvas.width; } }); } let running = true; document.addEventListener("visibilitychange", () => { running = !document.hidden; }); function animate() { if (running) drawConfetti(); requestAnimationFrame(animate); } createConfetti(); animate(); })();

Well done!

Congratulations on completing the writing workshop!

Progression

Profil

tkbappsid67

Mon grade

Ceinture blanche

Mes badges

tkbappsid67

Mon grade

Ceinture blanche