175 lines
7.4 KiB
JavaScript
175 lines
7.4 KiB
JavaScript
/*!
|
|
* Tectum
|
|
* Copyright © 2026 Machiel Heinen.
|
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
* Zie LICENTIE.md.
|
|
*/
|
|
// ============================================================
|
|
// core/granny-kit.js — onderdelen van de easter egg (core/shooter.js):
|
|
// oma-sprite, gibs, kreten,
|
|
// geluidseffecten, soundtrack en de omroeper à la Unreal Tournament.
|
|
// Plain script (geen ES-module).
|
|
// ============================================================
|
|
|
|
const GrannyKit = (() => {
|
|
// Oud vrouwtje met rollator, 16x25 pixels (twee loopbeelden).
|
|
const LADY = [
|
|
'......hHHh......',
|
|
'.....HHHHHH.....',
|
|
'....HHHHHHHH....',
|
|
'....HSSSSSSH....',
|
|
'....SGEsSGES....',
|
|
'....SSSssSSS....',
|
|
'.....SsrrsS.....',
|
|
'......SSSS......',
|
|
'.....WWWWWW.....',
|
|
'...DDDDWWDDDD...',
|
|
'..DDDDDDDDDDDD..',
|
|
'..DdDDDDDDDDdD..',
|
|
'..DdDDDDDDDDdD..',
|
|
'MSSMMMMMMMMMMSSM',
|
|
'.M.DDDDDDDDDD.M.',
|
|
'.MRRRRRRRRRRRRM.',
|
|
'.MRrRrRrRrRrRRM.',
|
|
'.MRRRRRRRRRRRRM.',
|
|
'.M..dDDDDDDd..M.',
|
|
'.M...DDDDDD...M.',
|
|
'.M....LL.LL...M.',
|
|
'.M....LL..LL..M.',
|
|
'.M....BB..BB..M.',
|
|
'KKK..........KKK',
|
|
'KkK..........KkK'
|
|
];
|
|
const LADY_B = LADY.slice(0, 20).concat([
|
|
'.M...LL..LL...M.',
|
|
'.M...LL...LL..M.',
|
|
'.M...BB...BB..M.',
|
|
'KkK..........KkK',
|
|
'KKK..........KKK'
|
|
]);
|
|
const PAL = { h: '#9a9aa8', H: '#d0d0da', S: '#f0c8a0', s: '#d09878', E: '#101010', G: '#707080', r: '#a04040',
|
|
W: '#f4f4f4', D: '#8040a0', d: '#5a2a78', M: '#b8c0c8', R: '#c03030', L: '#c8a888', B: '#402018', K: '#202020', k: '#606060' };
|
|
|
|
// Gibs: 8-bit stukjes oma (vlees, botjes, gebit, bril, handtas, breibol, rollatorwiel).
|
|
const GIBS = [
|
|
['..rr....', '.rRRr...', 'rRRRRr..', 'rRrRRRr.', '.rRRRRr.', '..rRRr..', '...rr...'],
|
|
['.rr.rr..', 'rRRrRRr.', 'rRRRRRr.', '.rRRRr..', '..rrr...'],
|
|
['ww....ww', 'wWw..wWw', '.wWwwWw.', '..wWWw..', '.wWwwWw.', 'wWw..wWw', 'ww....ww'],
|
|
['.pppppp.', 'pWWWWWWp', 'pWkWkWkp', 'pWWWWWWp', '.pppppp.'],
|
|
['GG..GG..', 'GbG.GbG.', 'GGGGGGG.', '........'],
|
|
['..nn....', '.n..n...', 'nNNNNNn.', 'NNNNNNN.', 'NNyNNNN.', 'NNNNNNN.'],
|
|
['.YYYY...', 'YyYYyY..', 'YYyYYy..', 'YyYYyY..', '.YYYY...', '.....Y..', '......Y.'],
|
|
['.KKKK...', 'KkkkkK..', 'KkMMkK..', 'KkMMkK..', 'KkkkkK..', '.KKKK...']
|
|
];
|
|
const GIB_PAL = { r: '#6a0808', R: '#c01818', w: '#a09880', W: '#f0ecdc', p: '#e07888', k: '#303030', G: '#606070', b: '#a0c8f0',
|
|
n: '#402018', N: '#7a4a22', y: '#d0b040', Y: '#c050a0', K: '#202020', M: '#b8c0c8' };
|
|
|
|
const SHOUTS = ['"Jongeman! Voeten vegen!"', '"In mijn tijd hadden we geen raketten!"', '"Ik bel je moeder!"',
|
|
'"Waar is de bingo?"', '"Dat dak lekt, hoor!"', '"Is dit wel vergund?"', '"Ik heb een abonnement op de bus!"'];
|
|
const DEATH_SHOUTS = ['"Oeeehh, mijn heup!"', '"Dat zeg ik tegen de wijkagent!"', '"Aaargh, de bingo..."'];
|
|
const HIT_SHOUTS = ['*BAM* met de handtas!', '*klets* met de rollator!', '"Neem een koekje, en dan wegwezen!"'];
|
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
|
|
// Pixelraster -> canvas (1 px per teken, optioneel met rand en één tintkleur;
|
|
// bottom = figuur op de onderrand, voor sprites die op de grond staan).
|
|
function pixelCanvas(rows, pal, opts) {
|
|
const o = Object.assign({ pad: 0, size: 0, tint: null, bottom: false }, opts || {});
|
|
const w = Math.max(...rows.map(r => r.length)), h = rows.length;
|
|
const c = document.createElement('canvas');
|
|
c.width = o.size || w + o.pad * 2;
|
|
c.height = o.size || h + o.pad * 2;
|
|
const g = c.getContext('2d');
|
|
const ox = Math.floor((c.width - w) / 2), oy = o.bottom ? c.height - h : Math.floor((c.height - h) / 2);
|
|
rows.forEach((row, y) => [...row].forEach((ch, x) => {
|
|
if (!pal[ch]) return;
|
|
g.fillStyle = o.tint || pal[ch];
|
|
g.fillRect(ox + x, oy + y, 1, 1);
|
|
}));
|
|
return c;
|
|
}
|
|
|
|
// Geluidseffecten: gesynthetiseerde ruis via WebAudio.
|
|
function createSfx() {
|
|
let audio = null;
|
|
return {
|
|
bang(len, freq, vol) {
|
|
try {
|
|
audio = audio || new (window.AudioContext || window.webkitAudioContext)();
|
|
const n = Math.floor(audio.sampleRate * len), buf = audio.createBuffer(1, n, audio.sampleRate), d = buf.getChannelData(0);
|
|
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3);
|
|
const src = audio.createBufferSource(), f = audio.createBiquadFilter(), g = audio.createGain();
|
|
src.buffer = buf; f.type = 'lowpass'; f.frequency.value = freq; g.gain.value = vol;
|
|
src.connect(f).connect(g).connect(audio.destination);
|
|
src.start();
|
|
} catch (e) { /* geen geluid, jammer */ }
|
|
},
|
|
close() { if (audio) audio.close(); audio = null; }
|
|
};
|
|
}
|
|
|
|
// Soundtrack: "Five Armies" Kevin MacLeod (incompetech.com), CC BY 4.0.
|
|
const MUSIC_CREDIT = '♪ "Five Armies" Kevin MacLeod (incompetech.com) · CC BY 4.0';
|
|
function createMusic() {
|
|
const music = new Audio('assets/audio/five-armies.mp3');
|
|
music.loop = true;
|
|
music.volume = 0.5;
|
|
return {
|
|
play() { music.currentTime = 0; music.play().catch(() => {}); }, // toetsdruk = gebruikersactie
|
|
stop() { music.pause(); }
|
|
};
|
|
}
|
|
|
|
// Omroeper (Web Speech API + grote tekst in labelEl).
|
|
// Multi-kill: kills binnen MULTI_WINDOW s na elkaar. Spree: kills zonder geraakt te worden.
|
|
const MULTI_WINDOW = 6;
|
|
const MULTI = { 2: 'Double Granny!', 3: 'Multi Granny!', 4: 'Mega Granny!', 5: 'Ultra Granny!', 6: 'M M M MONSTER GRANNY!' };
|
|
const SPREE = { 5: 'Bingo Spree!', 10: 'Rollator Rampage!', 15: 'Dominating the retirement home!', 20: 'Unstoppable!', 25: 'Godlike!', 30: 'Wicked Sick... of grannies!' };
|
|
function createAnnouncer(labelEl) {
|
|
let kills = 0, multi = 0, lastKillAt = -99, spree = 0, clock = 0, timer = 0;
|
|
function say(text) {
|
|
labelEl.textContent = text.replace(/^M M M /, 'M-M-M-');
|
|
labelEl.classList.remove('show'); void labelEl.offsetWidth; labelEl.classList.add('show');
|
|
timer = 2.2;
|
|
try {
|
|
const u = new SpeechSynthesisUtterance(text);
|
|
const v = speechSynthesis.getVoices().find(x => /^en(-|_)(US|GB)/i.test(x.lang) && /male|david|daniel|george/i.test(x.name))
|
|
|| speechSynthesis.getVoices().find(x => /^en/i.test(x.lang));
|
|
if (v) u.voice = v;
|
|
u.lang = 'en-US'; u.pitch = 0.1; u.rate = 0.8; u.volume = 1;
|
|
speechSynthesis.cancel();
|
|
speechSynthesis.speak(u);
|
|
} catch (e) { /* geen stem, dan alleen tekst */ }
|
|
}
|
|
return {
|
|
say,
|
|
get kills() { return kills; },
|
|
kill() {
|
|
kills++;
|
|
multi = clock - lastKillAt <= MULTI_WINDOW ? multi + 1 : 1;
|
|
lastKillAt = clock;
|
|
spree++;
|
|
if (kills === 1) say('First Blood!');
|
|
else if (multi >= 2) say(MULTI[Math.min(multi, 6)]);
|
|
else if (SPREE[spree]) say(SPREE[spree]);
|
|
return kills;
|
|
},
|
|
hurt() {
|
|
if (spree >= 5) say('Spree ended by a handbag!');
|
|
spree = 0;
|
|
},
|
|
reset() { kills = multi = spree = 0; lastKillAt = -99; },
|
|
update(dt) {
|
|
clock += dt;
|
|
if (timer > 0 && (timer -= dt) <= 0) labelEl.classList.remove('show');
|
|
},
|
|
stop() {
|
|
try { speechSynthesis.cancel(); } catch (e) { /* */ }
|
|
labelEl.classList.remove('show');
|
|
}
|
|
};
|
|
}
|
|
|
|
return { LADY, LADY_B, PAL, GIBS, GIB_PAL, SHOUTS, DEATH_SHOUTS, HIT_SHOUTS, MUSIC_CREDIT,
|
|
pick, pixelCanvas, createSfx, createMusic, createAnnouncer };
|
|
})();
|