edu-boardgame-generator/minigames/typing.js

170 lines
6 KiB
JavaScript

/**
* minigames/typing.js
* ⌨️ Tipp-Rennen — Tippe den Text so schnell wie möglich!
*/
window.MG_typing = (function() {
const ID = 'typing';
const EMOJI = '⌨️';
const NAME = 'Tipp-Rennen';
const DESC = 'Tippe das angezeigte Wort so schnell wie möglich!';
const CONTROLS = 'Tastatur';
const MULTI = 3;
const WORD_POOLS = {
easy: ['Katze','Hund','Baum','Haus','Ball','Buch','Schule','Spiel','Kind','Mond'],
medium: ['Abenteuer','Computer','Programm','Zauber','Kristall','Phantom','Roboter','Galaxie'],
hard: ['Dinosaurier','Wissenschaft','Programmierung','Abenteuerland','Weltentdecker'],
};
function run(wrap, W, H, cfg, onDone) {
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
const theme = cfg.theme || { primary: '#f43f5e' };
const WIN_WPM = cfg.winWpm || 30; // mind. 30 WPM zum Gewinnen
const ROUNDS = 3;
const allWords = [...WORD_POOLS.easy, ...WORD_POOLS.medium];
let words = [];
for (let i = 0; i < ROUNDS; i++) {
const pool = i < 2 ? WORD_POOLS.easy : WORD_POOLS.medium;
words.push(pool[Math.floor(Math.random() * pool.length)]);
}
let round = 0;
let typed = '';
let startTs = null;
let times = [];
let stopped = false;
let raf, endTimer, result = null;
// HTML-Input über dem Canvas
const input = document.createElement('input');
input.type = 'text';
input.autocomplete = 'off';
input.autocorrect = 'off';
input.autocapitalize = 'none';
input.spellcheck = false;
input.style.cssText = `
position:absolute;left:-9999px;top:0;opacity:0;width:1px;height:1px;
`;
wrap.style.position = 'relative';
wrap.appendChild(input);
setTimeout(() => input.focus(), 100);
canvas.addEventListener('click', () => input.focus());
input.addEventListener('input', () => {
typed = input.value;
if (!startTs && typed.length > 0) startTs = performance.now();
const word = words[round];
if (typed.toLowerCase() === word.toLowerCase()) {
const elapsed = (performance.now() - startTs) / 1000 / 60; // Minuten
const wpm = Math.round(word.length / 5 / elapsed); // Standard: 5 Zeichen = 1 Wort
times.push({ word, wpm });
typed = '';
input.value = '';
startTs = null;
round++;
if (round >= ROUNDS) {
const avgWpm = Math.round(times.reduce((a, b) => a + b.wpm, 0) / times.length);
result = avgWpm >= WIN_WPM ? 'win' : 'lose';
endTimer = setTimeout(() => onDone(result === 'win'), 1200);
}
}
});
function loop(ts) {
if (stopped) return;
raf = requestAnimationFrame(loop);
ctx.fillStyle = '#050508';
ctx.fillRect(0, 0, W, H);
if (round >= ROUNDS) {
const avgWpm = times.length
? Math.round(times.reduce((a,b) => a+b.wpm, 0) / times.length)
: 0;
MGAPI.resultScreen(ctx, W, H, result === 'win',
result === 'win' ? `${avgWpm} WPM — richtig schnell!` : `${avgWpm} WPM — weiter üben!`);
return;
}
const word = words[round];
const elapsed = startTs ? (ts - startTs) / 1000 : 0;
// Fortschrittsbalken (Timing-Druck)
const timeLimit = 10;
const prog = Math.min(1, elapsed / timeLimit);
if (prog >= 1 && !endTimer && !result) {
result = 'lose';
endTimer = setTimeout(() => onDone(false), 800);
}
const barW = W - 40;
MGAPI.roundRect(ctx, 20, H - 28, barW, 10, 4, 'rgba(255,255,255,0.07)', null);
const barColor = prog < 0.6 ? theme.primary : prog < 0.8 ? '#f59e0b' : '#ef4444';
MGAPI.roundRect(ctx, 20, H - 28, barW * (1 - prog), 10, 4, barColor, null);
// Rundenanzeige
MGAPI.text(ctx, `⌨️ Runde ${round + 1} / ${ROUNDS}`, W / 2, 20, { size: 12, color: theme.primary });
// Zu tippendes Wort
MGAPI.roundRect(ctx, W/2-140, H*0.2, 280, 60, 12,
'rgba(255,255,255,0.05)', `${theme.primary}44`);
MGAPI.text(ctx, word, W / 2, H * 0.2 + 34,
{ size: 28, family: "'Fredoka One',cursive", color: '#fff' });
// Eingabe-Anzeige (zeichenweiser Vergleich)
const charW = 26;
const startX = W / 2 - (word.length * charW) / 2;
const charY = H * 0.55;
MGAPI.text(ctx, 'Tippe:', W / 2, charY - 24, { size: 11, color: 'rgba(255,255,255,0.4)' });
for (let i = 0; i < word.length; i++) {
const cx = startX + i * charW + charW / 2;
const tc = (typed[i] || '').toLowerCase();
const wc = word[i].toLowerCase();
let color;
if (!typed[i]) color = 'rgba(255,255,255,0.2)';
else if (tc === wc) color = '#22c55e';
else color = '#ef4444';
MGAPI.roundRect(ctx, startX + i * charW, charY - 4, charW - 2, 32, 4,
'rgba(255,255,255,0.04)', `${color}66`);
MGAPI.text(ctx, typed[i] || word[i], cx, charY + 12, { size: 18, color });
}
// Cursor-Blinken
if (startTs || typed.length === 0) {
const cx = startX + Math.min(typed.length, word.length) * charW + 2;
if (Math.floor(ts / 500) % 2 === 0) {
ctx.fillStyle = theme.primary;
ctx.fillRect(cx, charY, 3, 32);
}
}
// letzten WPM anzeigen
if (times.length > 0) {
const last = times[times.length - 1];
MGAPI.text(ctx, `Letztes: ${last.wpm} WPM`, W / 2, H - 42, { size: 11, color: 'rgba(255,255,255,0.35)' });
}
}
raf = requestAnimationFrame(loop);
return {
stop() {
stopped = true;
clearTimeout(endTimer);
cancelAnimationFrame(raf);
if (input.parentNode) input.parentNode.removeChild(input);
},
};
}
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
})();