v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+174
View File
@@ -0,0 +1,174 @@
/*!
* 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 };
})();