v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b5510d56c7
commit
ceda0b554d
+3
-26
@@ -4,26 +4,6 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/build-stages.js — Bouwfasen (window.BuildStages): de volgorde waarin
|
||||
// een unit in de hal wordt opgebouwd. Eén begrip voor zowel de weergave
|
||||
// (schuif "Bouwfase" in de 3D) als het weglaten van verborgen delen:
|
||||
//
|
||||
// 0 regelwerk vloer-, wand- en dakbalken
|
||||
// 1 elektra installatiebuizen, dozen en meterkast (ElektraModel)
|
||||
// 2 beplating spaanplaat binnen, OSB vloer/plafond/dakbeschot, cementplaat buiten
|
||||
// 3 doek vochtdoek
|
||||
// 4 latwerk kruislat, trasraamplaat
|
||||
// 5 gevel rabat, steen, kozijnen, dakbedekking (dakleer, riet, felsplaten), sandwichpanelen
|
||||
// 6 afwerking dakrand, verlichting, vloerafwerking, verf binnen
|
||||
//
|
||||
// Fasen zijn cumulatief: fase n toont alles t/m n. Een laag die in de
|
||||
// gekozen fase aan beide zijden is ingesloten door latere lagen wordt niet
|
||||
// gebouwd (bijv. stijlen tussen spaanplaat en doek, latten achter de
|
||||
// rabat). De specificaties rekenen altijd met alle lagen.
|
||||
//
|
||||
// Plain script (geen ES-module); het model-deel heeft geen DOM nodig.
|
||||
// ============================================================
|
||||
|
||||
window.BuildStages = (() => {
|
||||
const LIST = [
|
||||
@@ -38,14 +18,9 @@ window.BuildStages = (() => {
|
||||
const FINAL = LIST.length - 1;
|
||||
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
|
||||
|
||||
// Fase (getal of undefined = afgewerkt) normaliseren.
|
||||
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
|
||||
// Is laag `key` zichtbaar in fase `stage`?
|
||||
const shows = (stage, key) => clamp(stage) >= INDEX[key];
|
||||
|
||||
// Schuif onderin een 3D-weergave (DOM). pane = de weergave (position:
|
||||
// relative); onChange(stage) bij elke stap. Geeft { set(stage), el,
|
||||
// extra } terug; extra = vrije rij onder de schuif (bijv. doorsnede).
|
||||
function createBar(pane, stage, onChange) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'stage-bar';
|
||||
@@ -75,8 +50,10 @@ window.BuildStages = (() => {
|
||||
sync();
|
||||
onChange(clamp(Number(range.value)));
|
||||
});
|
||||
// Toetsen in de weergave mogen de schuif niet bedienen (T, R, ...).
|
||||
// T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
|
||||
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
|
||||
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
|
||||
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
|
||||
sync();
|
||||
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
|
||||
}
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/camera-memory.js — onthoudt de 3D-camerastand (positie + draaipunt)
|
||||
// per weergave, zodat wisselen tussen onderdelen/tabbladen of een her-mount
|
||||
// na een wijziging niet terugspringt naar de standaardcamera.
|
||||
// Sleutels komen van de aanroeper (bijv. "tab3:wall:A:voor"). Alleen in het
|
||||
// geheugen van deze sessie.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
window.CameraMemory = (() => {
|
||||
const store = new Map();
|
||||
@@ -21,7 +13,6 @@ window.CameraMemory = (() => {
|
||||
store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() });
|
||||
}
|
||||
|
||||
// true als er een opgeslagen stand was (en die is toegepast).
|
||||
function restore(key, camera, controls) {
|
||||
const m = key && store.get(key);
|
||||
if (!m) return false;
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/canvas-labels.js — gedeelde stijl voor maat-labels in de 2D-canvassen
|
||||
// (blauwe maatlijn + "X: 6000mm"-tekst, zoals het wand-vooraanzicht). Gebruikt
|
||||
// door wand, vloer en unit, zodat alle labels er hetzelfde uitzien.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
const CANVAS_LABEL_COLOR = '#4da6ff';
|
||||
|
||||
// Tekent een maatlijn van (x1,y1) naar (x2,y2) plus tekst op (textX,textY).
|
||||
// Een verticale lijn (x1 === x2) krijgt de tekst 90° gedraaid.
|
||||
function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = CANVAS_LABEL_COLOR;
|
||||
|
||||
+6
-74
@@ -4,20 +4,8 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/geometry.js — gedeelde, pure math. Geen DOM, geen THREE-afhankelijkheid
|
||||
// behalve shapeAreaWithHoles (die werkt met elk object dat .getPoints()/.holes
|
||||
// heeft, dus ook met THREE.Shape). Plain <script> (geen module), zodat dit
|
||||
// ook werkt bij rechtstreeks openen van index.html (file://) — daar breken
|
||||
// ES-module-imports door browser-CORS.
|
||||
//
|
||||
// Wordt geladen VOOR wall.js en floor.js: de functies hieronder zijn dus
|
||||
// gewoon globaal beschikbaar in beide, zonder duplicatie.
|
||||
// ============================================================
|
||||
// Plain script, geen ES-module: imports breken bij file:// (CORS).
|
||||
|
||||
// Regelwerk-verdeling (h.o.h.): bepaalt hoeveel balken/stijlen er in een
|
||||
// gegeven overspanning passen, gegeven een minimale/maximale hart-op-hart-
|
||||
// afstand. Gebruikt door wand-stijlen EN vloerbalken (zelfde algoritme).
|
||||
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||
if (spanWidth <= 0) return [];
|
||||
let numBays = Math.ceil(spanWidth / maxSp);
|
||||
@@ -35,7 +23,6 @@ function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
// Oppervlakte van een simpele polygon (shoelace-formule).
|
||||
function polygonArea(points) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
@@ -46,11 +33,7 @@ function polygonArea(points) {
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
// Sutherland-Hodgman clip van een (convexe) polygon tegen een horizontale
|
||||
// halfvlak-grens. Gebruikt om bijv. de wand-omtrek (het schuine trapezium) te
|
||||
// knippen tot losse horizontale banen voor het vochtdoek.
|
||||
function clipPolygonYLE(points, yLimit) {
|
||||
// Houdt het deel met y <= yLimit
|
||||
const result = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const cur = points[i];
|
||||
@@ -71,7 +54,6 @@ function clipPolygonYLE(points, yLimit) {
|
||||
return result;
|
||||
}
|
||||
function clipPolygonYGE(points, yLimit) {
|
||||
// Houdt het deel met y >= yLimit
|
||||
const result = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const cur = points[i];
|
||||
@@ -92,26 +74,17 @@ function clipPolygonYGE(points, yLimit) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Oppervlakte van een THREE.Shape inclusief aftrek van zijn holes.
|
||||
function shapeAreaWithHoles(shape) {
|
||||
let area = polygonArea(shape.getPoints());
|
||||
(shape.holes || []).forEach(hole => { area -= polygonArea(hole.getPoints()); });
|
||||
return area;
|
||||
}
|
||||
|
||||
// Simpele euclidische afstand — gebruikt door floor.js voor de
|
||||
// cirkel-overlapcheck (2D, dus geen 1D-ankerlogica zoals bij wand-openingen).
|
||||
function dist2D(a, b) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
// Zaagplan: verdeelt stukken (lengtes in mm) over handelslengtes met zo min
|
||||
// mogelijk snijverlies. Stukken langer dan de langste handelslengte worden
|
||||
// eerst opgeknipt (las/stuik in het werk). Daarna best-fit-decreasing: elk
|
||||
// stuk gaat in de staaf waar het het krapst past; tot slot krijgt elke staaf
|
||||
// de kortste handelslengte waar zijn stukken nog in passen.
|
||||
// Resultaat: [{ stock, cuts: [lengtes], used, waste }]. Zaagsnede-breedte
|
||||
// wordt verwaarloosd.
|
||||
// Zaagsnede-breedte wordt verwaarloosd.
|
||||
function planStockCutting(pieceLengths, stockLengths) {
|
||||
const stocks = stockLengths.slice().sort((a, b) => a - b);
|
||||
const maxStock = stocks[stocks.length - 1];
|
||||
@@ -142,7 +115,6 @@ function planStockCutting(pieceLengths, stockLengths) {
|
||||
return bars;
|
||||
}
|
||||
|
||||
// Samenvatting van een zaagplan: aantal staven per handelslengte + totalen.
|
||||
function summarizeStockPlan(bars) {
|
||||
const byStock = new Map();
|
||||
bars.forEach(b => byStock.set(b.stock, (byStock.get(b.stock) || 0) + 1));
|
||||
@@ -153,25 +125,8 @@ function summarizeStockPlan(bars) {
|
||||
};
|
||||
}
|
||||
|
||||
// Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire)
|
||||
// bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }):
|
||||
// rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar
|
||||
// (x1, y1r); schuin bovenin mag, onderin niet). Uitsparingen die over de
|
||||
// rand van de strook vallen worden een echte inkeping in de omtrek (i.p.v.
|
||||
// een gat dat net binnen de rand wordt afgekapt — dat liet een haarfijn
|
||||
// reepje materiaal met zichtbare randlijnen door de opening lopen). Een
|
||||
// uitsparing over de volle breedte splitst de strook in losse delen; een
|
||||
// uitsparing tot in de (schuine) bovenrand wordt een inkeping in de bovenrand.
|
||||
// opts (optioneel):
|
||||
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan).
|
||||
// extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat
|
||||
// de bovenrand tussen twee hoekpunten steeds lineair is.
|
||||
// Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken
|
||||
// en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant,
|
||||
// onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet
|
||||
// kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen
|
||||
// tussen vol en leeg worden tot lussen aaneengeregen.
|
||||
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }].
|
||||
// Een uitsparing over de rand wordt een inkeping, geen afgekapt gat (dat liet een haarfijn
|
||||
// reepje materiaal met randlijnen door de opening lopen). Schuin (y1r) alleen bovenin.
|
||||
function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
const eps = 1e-9;
|
||||
const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity;
|
||||
@@ -186,14 +141,12 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
});
|
||||
const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x));
|
||||
|
||||
// Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand.
|
||||
const raw = [() => yBottom, top];
|
||||
rs.forEach(r => raw.push(() => r.y0, r.topAt));
|
||||
const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x)));
|
||||
const lines = raw.map(clamp);
|
||||
|
||||
let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
|
||||
// Snijpunten van de lijnen (ook met de bovenrand/onderkant) als extra kolomranden.
|
||||
const cuts = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1];
|
||||
@@ -206,7 +159,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
}
|
||||
xs = uniq(xs.concat(cuts));
|
||||
|
||||
// Per kolom: cellen tussen opeenvolgende (verschillende) lijnen.
|
||||
const cols = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
|
||||
@@ -222,27 +174,24 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
cols.push({ a, b, cells });
|
||||
}
|
||||
|
||||
// Hoekpunten gedeeld via een register (bijna-gelijke punten = hetzelfde punt).
|
||||
const verts = [];
|
||||
const vid = (x, y) => {
|
||||
let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7);
|
||||
if (k < 0) { k = verts.length; verts.push({ x, y }); }
|
||||
return k;
|
||||
};
|
||||
const edges = new Map(); // van -> [naar, ...]
|
||||
const edges = new Map();
|
||||
const addEdge = (p, q) => {
|
||||
const a = vid(p.x, p.y), b = vid(q.x, q.y);
|
||||
if (a === b) return;
|
||||
if (!edges.has(a)) edges.set(a, []);
|
||||
edges.get(a).push(b);
|
||||
};
|
||||
// Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links).
|
||||
cols.forEach(col => col.cells.forEach((c, k) => {
|
||||
if (!c.filled) return;
|
||||
if (!(k > 0 && col.cells[k - 1].filled && col.cells[k - 1].hi === c.lo)) addEdge({ x: col.a, y: c.lo.a }, { x: col.b, y: c.lo.b });
|
||||
if (!(k < col.cells.length - 1 && col.cells[k + 1].filled && col.cells[k + 1].lo === c.hi)) addEdge({ x: col.b, y: c.hi.b }, { x: col.a, y: c.hi.a });
|
||||
}));
|
||||
// Verticale randen op elke kolomgrens: waar links vol en rechts leeg is (of andersom).
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const x = xs[i];
|
||||
const left = i > 0 ? cols[i - 1].cells.filter(c => c.filled).map(c => [c.lo.b, c.hi.b]) : [];
|
||||
@@ -257,7 +206,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
|
||||
const loops = [];
|
||||
edges.forEach((list, start) => {
|
||||
while (list.length) {
|
||||
@@ -293,7 +241,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
return c;
|
||||
};
|
||||
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
|
||||
// Gat bij de kleinste buitenlus die het bevat.
|
||||
outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer));
|
||||
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
|
||||
const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 };
|
||||
@@ -303,11 +250,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
return outers;
|
||||
}
|
||||
|
||||
// Omtrek van de vereniging van as-evenwijdige rechthoeken ({x0, x1, z0, z1}),
|
||||
// als gesloten lussen van punten {x, z}. Buitenlussen zijn tegen de klok in
|
||||
// (positieve oppervlakte in het x/z-vlak), gaten met de klok mee. Collineaire
|
||||
// tussenpunten zijn verwijderd. Gesorteerd op oppervlakte (grootste eerst).
|
||||
// Gebruikt voor de dakrand (boeidelen/daktrim) over meerdere units heen.
|
||||
function rectUnionLoops(rects) {
|
||||
const eps = 1e-6;
|
||||
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
|
||||
@@ -350,16 +292,11 @@ function rectUnionLoops(rects) {
|
||||
return loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
|
||||
}
|
||||
|
||||
// Buitenwaartse normaal (as-evenwijdig, lengte 1) van ribbe a -> b van een
|
||||
// lus tegen de klok in (positieve oppervlakte in x/z).
|
||||
function rectilinearNormal(a, b) {
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||
return { x: dz / len, z: -dx / len };
|
||||
}
|
||||
|
||||
// Verschuift een rechthoekige (as-evenwijdige) lus tegen de klok in over
|
||||
// afstand d naar buiten. Collineaire tussenpunten (bijv. een unit-hoek midden
|
||||
// in een rechte gevel) blijven behouden en schuiven loodrecht mee.
|
||||
function offsetRectilinearLoop(pts, d) {
|
||||
const n = pts.length;
|
||||
return pts.map((p, k) => {
|
||||
@@ -370,17 +307,12 @@ function offsetRectilinearLoop(pts, d) {
|
||||
});
|
||||
}
|
||||
|
||||
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
|
||||
// (zelfde as + normaal + gevelvlak, aansluitend of overlappend). segs: [{ axis ('x'|'z'),
|
||||
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
|
||||
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
|
||||
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
|
||||
function groupFacadeRuns(segs, opts) {
|
||||
const lineTol = (opts && opts.lineTol) || 1, gapTol = (opts && opts.gapTol) || 5;
|
||||
const runs = [];
|
||||
segs.slice().sort((a, b) => a.s0 - b.s0).forEach(sg => {
|
||||
const run = runs.find(r => r.axis === sg.axis && Math.abs(r.normal.x - sg.normal.x) < 1e-6 && Math.abs(r.normal.z - sg.normal.z) < 1e-6 &&
|
||||
Math.abs(r.line - sg.line) < lineTol && sg.s0 <= r.s1 + gapTol); // aansluitend of overlappend
|
||||
Math.abs(r.line - sg.line) < lineTol && sg.s0 <= r.s1 + gapTol);
|
||||
if (run) { run.segs.push(sg); run.s1 = Math.max(run.s1, sg.s1); }
|
||||
else runs.push(Object.assign({}, sg, { s0: sg.s0, s1: sg.s1, segs: [sg] }));
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/*!
|
||||
* 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 };
|
||||
})();
|
||||
+1
-6
@@ -4,12 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/license.js — de licentietekst voor "Over deze software". Kopie van
|
||||
// LICENTIE.md (dat bestand is leidend; bij een wijziging beide aanpassen).
|
||||
// Als JS-string opgenomen omdat fetch() van lokale bestanden via file://
|
||||
// niet betrouwbaar werkt.
|
||||
// ============================================================
|
||||
// Kopie van LICENTIE.md (leidend): bij een wijziging beide aanpassen.
|
||||
|
||||
window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-25
@@ -4,28 +4,16 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/pdf-sheet.js — gedeelde bouwstenen voor de A3-tekenbladen (jsPDF):
|
||||
// kleuren, nieuw document, maatlijn en titelblok. Gebruikt door WallPdf,
|
||||
// FloorPdf en de unit-PDF, zodat elk blad er identiek uitziet.
|
||||
// Plain script (geen ES-module), geladen na jspdf.umd.min.js.
|
||||
// ============================================================
|
||||
|
||||
window.PdfSheet = (() => {
|
||||
// Kleurlogica: rood = houtwerk-omtrekken, blauw = hoofdmaatvoering,
|
||||
// zwart = opening-kruis, posnum-labels en de onderste maatketens.
|
||||
const COLORS = { RED: [190, 25, 25], BLUE: [25, 50, 180], BLACK: [0, 0, 0], GREY: [130, 130, 130] };
|
||||
|
||||
// Veldnamen van de "Project eigenschappen"-modal van de wand-/vloer-editor.
|
||||
const PROJECT_DOM_IDS = {
|
||||
naam: 'inp-proj-naam', plaats: 'inp-proj-plaats', straat: 'inp-proj-straat', nummer: 'inp-proj-nummer',
|
||||
tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking'
|
||||
};
|
||||
|
||||
// Afbeeldingen (bijv. textuur-voorvertoningen) voor in de PDF. generate()
|
||||
// is synchroon, dus vooraf laden (preloadImage, bijv. zodra een materiaal
|
||||
// gekozen is); imageData geeft de data-URL, of null als hij (nog) niet
|
||||
// geladen is (dan tekent de aanroeper een kleurvlak).
|
||||
// generate() is synchroon: afbeeldingen vooraf laden met preloadImage.
|
||||
const imageCache = {};
|
||||
function preloadImage(url) {
|
||||
if (!url || url in imageCache || typeof Image === 'undefined' || typeof document === 'undefined') return;
|
||||
@@ -37,7 +25,7 @@ window.PdfSheet = (() => {
|
||||
c.width = img.naturalWidth; c.height = img.naturalHeight;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
imageCache[url] = c.toDataURL('image/png');
|
||||
} catch (e) { /* laat null: kleurvlak */ }
|
||||
} catch (e) { }
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
@@ -57,9 +45,6 @@ window.PdfSheet = (() => {
|
||||
return project;
|
||||
}
|
||||
|
||||
// Maatlijn met kleine loodrechte eindstreepjes ("|") op beide
|
||||
// uiteinden, zodat duidelijk is waar de maat precies begint/eindigt
|
||||
// i.p.v. een lijn die zonder markering in de lucht lijkt te eindigen.
|
||||
const DIM_TICK = 1.5;
|
||||
function dimLine(doc, x1, y1, x2, y2) {
|
||||
doc.line(x1, y1, x2, y2);
|
||||
@@ -70,10 +55,6 @@ window.PdfSheet = (() => {
|
||||
doc.line(x2 - tx, y2 - ty, x2 + tx, y2 + ty);
|
||||
}
|
||||
|
||||
// Titelblok (rechtsonder), in twee kolommen zodat het weinig hoogte
|
||||
// inneemt, met het logo (core/logo-data.js) rechtsonder.
|
||||
// project: { naam, plaats, straat, nummer, tekenaar, opmerking };
|
||||
// opts: { titel, element, blad } (blad bijv. "2 / 7").
|
||||
function titleBlock(doc, project, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const p = project || {};
|
||||
@@ -104,7 +85,6 @@ window.PdfSheet = (() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Scheidingslijn + onderstrook: blad links, logo rechts.
|
||||
const stripY = tbY0 + 25;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
@@ -116,7 +96,7 @@ window.PdfSheet = (() => {
|
||||
}
|
||||
const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55);
|
||||
if (window.TECTUM_LOGO) {
|
||||
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: 1x in het bestand, gecomprimeerd
|
||||
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: logo 1x in het bestand
|
||||
} else {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
@@ -124,8 +104,6 @@ window.PdfSheet = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Eenvoudige tabel: headers + rijen (arrays van strings) vanaf (x, y) met
|
||||
// vaste kolom-x'en. Geeft de y-positie onder de tabel terug.
|
||||
function table(doc, x, y, colX, headers, rows, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const o = opts || {};
|
||||
|
||||
@@ -4,18 +4,6 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// js/core/pdf-writer.js — minimale, dependency-vrije PDF-generator.
|
||||
// Schrijft geldige PDF 1.4 bytes rechtstreeks (geen jsPDF: geen
|
||||
// netwerktoegang om dat te vendoren). Ondersteunt lijnen, rechthoeken,
|
||||
// tekst (standaard Helvetica, geen embedding nodig) en kleuren, over
|
||||
// meerdere pagina's. Coordinaten in PDF-punten (1pt = 1/72 inch),
|
||||
// oorsprong linksonder — precies zoals PDF dat zelf verwacht.
|
||||
//
|
||||
// Puur string/byte-opbouw, geen browser-specifieke API's nodig om het
|
||||
// DOCUMENT te bouwen (alleen het downloaden ervan is browser-specifiek,
|
||||
// zie savePdfBlob hieronder).
|
||||
// ============================================================
|
||||
|
||||
function createPdfDocument() {
|
||||
const objects = [];
|
||||
@@ -46,7 +34,6 @@ function createPdfDocument() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
|
||||
function estimateTextWidth(str, size) {
|
||||
return str.length * size * 0.55;
|
||||
}
|
||||
@@ -145,9 +132,6 @@ function createPdfDocument() {
|
||||
return { addPage, build };
|
||||
}
|
||||
|
||||
// Zet een Latin1-string (elk char = 1 byte, zie hierboven) om naar een
|
||||
// Blob en start een download. Browser-specifiek, puur I/O — de opbouw
|
||||
// hierboven is dat niet.
|
||||
function savePdfBlob(latin1String, filename) {
|
||||
const bytes = new Uint8Array(latin1String.length);
|
||||
for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff;
|
||||
|
||||
+3
-32
@@ -4,48 +4,22 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/ribbon.js — Ribbon (window.Ribbon): de bedieningsbalk bovenin. Eén
|
||||
// plek voor alle commando's en instellingen; de weergaven eronder blijven
|
||||
// vrij.
|
||||
//
|
||||
// Tabbladen komen uit bronnen: 'app' (Bestand, Beeld, Extra) en 'editor'
|
||||
// (de actieve editor, bijv. Start, Gevel, Dak, ... en een contextueel
|
||||
// tabblad voor de selectie). Tabbladen met dezelfde sleutel uit meerdere
|
||||
// bronnen worden samengevoegd (bijv. Beeld: weergave-opties van de woning
|
||||
// + vensterknoppen van de app).
|
||||
//
|
||||
// Ribbon.setSource('editor', [{
|
||||
// key, label, order (sortering), contextual (gekleurd, achteraan),
|
||||
// groups: () => [{ label, html }], // inhoud, opnieuw bij elke weergave
|
||||
// wire: (body) => {}, // events koppelen na het tekenen
|
||||
// onShow: () => {} // tabblad wordt actief
|
||||
// }]);
|
||||
// Ribbon.activate(key) · Ribbon.refresh() · Ribbon.active() · Ribbon.onChange(fn)
|
||||
//
|
||||
// Groepen: een raster van 3 rijen hoog; velden (UI-kit) staan per kolom
|
||||
// onder elkaar. Grote knop: Ribbon.big(...), kleine: Ribbon.small(...).
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
window.Ribbon = (() => {
|
||||
const sources = {}; // naam -> [tab]
|
||||
const sources = {};
|
||||
const SOURCE_ORDER = ['app', 'editor'];
|
||||
let root = null, tabsEl = null, bodyEl = null;
|
||||
let activeKey = null, lastPlainKey = null;
|
||||
let auto = true; // tabblad nog niet door de gebruiker gekozen: voorkeur 'start'
|
||||
let auto = true;
|
||||
let collapsed = false, notified = null;
|
||||
const listeners = [];
|
||||
const PREF_KEY = 'tectum.ribbon.collapsed';
|
||||
|
||||
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
|
||||
// Grote knop (3 rijen hoog): icoon boven het label.
|
||||
const big = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
// Kleine knop (1 rij).
|
||||
const small = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
// Waarde-regel (alleen lezen) binnen een groep.
|
||||
const kv = (label, value, unit) =>
|
||||
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
|
||||
|
||||
@@ -105,7 +79,7 @@ window.Ribbon = (() => {
|
||||
tab.parts.forEach(p => p.onShow && p.onShow());
|
||||
}
|
||||
|
||||
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { /* alleen deze sessie */ } }
|
||||
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
|
||||
|
||||
function init(el) {
|
||||
root = el;
|
||||
@@ -128,8 +102,6 @@ window.Ribbon = (() => {
|
||||
render();
|
||||
}
|
||||
|
||||
// Bron vervangen. Een nieuw contextueel tabblad (andere sleutel) wordt
|
||||
// actief als opts.focus; verdwijnt het actieve, dan terug naar het vorige.
|
||||
function setSource(name, tabs, opts) {
|
||||
sources[name] = tabs || [];
|
||||
const ctx = (tabs || []).find(t => t.contextual);
|
||||
@@ -138,7 +110,6 @@ window.Ribbon = (() => {
|
||||
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
|
||||
}
|
||||
|
||||
// fn(key) bij elke wissel van het actieve tabblad.
|
||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
||||
|
||||
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/shooter.js — easter egg: schieten in de loopmodus (Ctrl+Shift+Alt+L).
|
||||
// klik in beeld muis vangen (Esc = loslaten)
|
||||
// linkermuisknop vuren
|
||||
// 1 / 2 / scrollwiel pistool / raketwerper
|
||||
// Kogelgaten en roetplekken blijven op wanden/dak/vloer achter, raketten
|
||||
// laten ook wat rondvliegend metselwerk achter. Bewust simpel en buggy.
|
||||
// Plain script (geen ES-module); gebruikt THREE.
|
||||
//
|
||||
// const gun = createShooter({ scene, camera, domElement, pane, walk, targets: () => [..] });
|
||||
// gun.start(); gun.update(dt); gun.stop(); gun.dispose();
|
||||
// ============================================================
|
||||
|
||||
function createShooter(o) {
|
||||
const { scene, camera, domElement, pane, walk } = o;
|
||||
const group = new THREE.Group();
|
||||
group.name = 'shooter';
|
||||
let active = false, weapon = 0, cooldown = 0, recoil = 0;
|
||||
const decals = [], rockets = [], blasts = [], debris = [];
|
||||
const MAX_DECALS = 250;
|
||||
|
||||
// --- Texturen (canvas) ---
|
||||
function canvasTex(size, draw) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
draw(c.getContext('2d'), size);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
return t;
|
||||
}
|
||||
const holeTex = canvasTex(64, (g, s) => {
|
||||
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||
r.addColorStop(0, 'rgba(0,0,0,1)'); r.addColorStop(0.18, 'rgba(10,8,6,1)');
|
||||
r.addColorStop(0.3, 'rgba(60,50,40,0.8)'); r.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||
g.strokeStyle = 'rgba(20,15,10,0.7)'; g.lineWidth = 1.5;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = Math.random() * Math.PI * 2, l = s * (0.25 + Math.random() * 0.2);
|
||||
g.beginPath(); g.moveTo(s / 2, s / 2); g.lineTo(s / 2 + Math.cos(a) * l, s / 2 + Math.sin(a) * l); g.stroke();
|
||||
}
|
||||
});
|
||||
const scorchTex = canvasTex(128, (g, s) => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const x = s / 2 + (Math.random() - 0.5) * s * 0.5, y = s / 2 + (Math.random() - 0.5) * s * 0.5;
|
||||
const rr = s * (0.1 + Math.random() * 0.25);
|
||||
const r = g.createRadialGradient(x, y, 0, x, y, rr);
|
||||
r.addColorStop(0, 'rgba(15,10,5,0.5)'); r.addColorStop(1, 'rgba(15,10,5,0)');
|
||||
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||
}
|
||||
g.fillStyle = 'rgba(255,120,20,0.6)';
|
||||
for (let i = 0; i < 25; i++) g.fillRect(s / 2 + (Math.random() - 0.5) * s * 0.4, s / 2 + (Math.random() - 0.5) * s * 0.4, 2, 2);
|
||||
});
|
||||
const fireTex = canvasTex(64, (g, s) => {
|
||||
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||
r.addColorStop(0, 'rgba(255,255,220,1)'); r.addColorStop(0.3, 'rgba(255,180,40,0.9)');
|
||||
r.addColorStop(0.7, 'rgba(200,50,0,0.4)'); r.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||
});
|
||||
// Puin krijgt een willekeurige steentextuur uit de bibliotheek (als die laadt).
|
||||
const brickTex = new THREE.TextureLoader().load(['assets/textures/buitenmuur/R200 - Witte voeg.jpg', 'assets/textures/buitenmuur/K739 - Witte voeg.jpg', 'assets/textures/buitenmuur/R480 - Witte voeg.jpg'][Math.floor(Math.random() * 3)]);
|
||||
const decalMat = (map) => new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 });
|
||||
const holeMat = decalMat(holeTex), scorchMat = decalMat(scorchTex);
|
||||
const debrisMat = new THREE.MeshStandardMaterial({ map: brickTex, roughness: 0.9 });
|
||||
const fireMat = new THREE.SpriteMaterial({ map: fireTex, blending: THREE.AdditiveBlending, depthWrite: false });
|
||||
|
||||
// --- Wapens (vastgeplakt aan de camera) ---
|
||||
const metal = new THREE.MeshStandardMaterial({ color: 0x2a2c30, metalness: 0.7, roughness: 0.35 });
|
||||
const olive = new THREE.MeshStandardMaterial({ color: 0x4b5a32, roughness: 0.6 });
|
||||
function box(w, h, d, m, x, y, z) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); return b; }
|
||||
const pistol = new THREE.Group();
|
||||
pistol.add(box(0.035, 0.045, 0.2, metal, 0, 0, -0.1), box(0.03, 0.09, 0.04, metal, 0, -0.055, -0.02));
|
||||
const launcher = new THREE.Group();
|
||||
const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7, 12, 1, true), olive);
|
||||
tube.material.side = THREE.DoubleSide;
|
||||
tube.rotation.x = Math.PI / 2; tube.position.z = -0.25;
|
||||
launcher.add(tube, box(0.03, 0.09, 0.04, metal, 0, -0.08, -0.05), box(0.02, 0.05, 0.03, metal, 0.05, 0.05, -0.2));
|
||||
const flash = new THREE.Sprite(fireMat.clone());
|
||||
flash.scale.set(0.15, 0.15, 1);
|
||||
flash.visible = false;
|
||||
const viewModel = new THREE.Group();
|
||||
viewModel.add(pistol, launcher, flash);
|
||||
const gunLight = new THREE.PointLight(0xffaa44, 0, 6);
|
||||
group.add(viewModel, gunLight);
|
||||
|
||||
// --- HUD ---
|
||||
const hud = document.createElement('div');
|
||||
hud.className = 'shooter-hud';
|
||||
hud.innerHTML = '<div class="shooter-cross"></div><div class="shooter-weapon"></div><div class="shooter-credit"></div><div class="shooter-kills"></div><div class="shooter-shout"></div><div class="shooter-ouch"></div><div class="shooter-announce"></div><div class="shooter-help">Klik = muis vangen · linkerknop = vuren · 1/2 of scroll = wapen · Esc = muis los · Ctrl+Shift+Alt+L = stoppen</div>';
|
||||
pane.appendChild(hud);
|
||||
hud.querySelector('.shooter-credit').textContent = GrannyKit.MUSIC_CREDIT;
|
||||
const weaponLabel = hud.querySelector('.shooter-weapon');
|
||||
const killsLabel = hud.querySelector('.shooter-kills');
|
||||
const shoutLabel = hud.querySelector('.shooter-shout');
|
||||
const ouch = hud.querySelector('.shooter-ouch');
|
||||
let shoutTimer = 0;
|
||||
function shout(text) { shoutLabel.textContent = text; shoutLabel.classList.add('show'); shoutTimer = 2.5; }
|
||||
function setKills(n) { killsLabel.textContent = "Oma's: " + n; }
|
||||
const announcer = GrannyKit.createAnnouncer(hud.querySelector('.shooter-announce'));
|
||||
|
||||
function selectWeapon(i) {
|
||||
weapon = (i + 2) % 2;
|
||||
pistol.visible = weapon === 0;
|
||||
launcher.visible = weapon === 1;
|
||||
weaponLabel.textContent = weapon === 0 ? '[1] Pistool ∞' : '[2] Raketwerper ∞';
|
||||
flash.position.set(0, 0, weapon === 0 ? -0.22 : -0.62);
|
||||
}
|
||||
selectWeapon(0);
|
||||
|
||||
// --- Vijand: oud vrouwtje met rollator (8-bit sprite, Doom-stijl) ---
|
||||
// Eén tegelijk: spawnt op 12-18 m, schuifelt op je af, na de kill komt de volgende.
|
||||
function spriteTex(rows, tint) {
|
||||
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.PAL, { size: 32, tint, bottom: true }));
|
||||
t.magFilter = t.minFilter = THREE.NearestFilter;
|
||||
t.generateMipmaps = false;
|
||||
return t;
|
||||
}
|
||||
const ladyTex = [spriteTex(GrannyKit.LADY), spriteTex(GrannyKit.LADY_B)];
|
||||
const ladyHurtTex = spriteTex(GrannyKit.LADY, '#ff3030');
|
||||
const LADY_H = 1.6 * 32 / 25; // sprite-hoogte (m) zodat de figuur ~1,6 m is
|
||||
let mob = null, spawnTimer = 1.5, attackTimer = 0, animT = 0;
|
||||
function spawnMob() {
|
||||
const me = camera.position;
|
||||
let x = me.x, z = me.z;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const a = Math.random() * Math.PI * 2, d = 12 + Math.random() * 6;
|
||||
x = me.x + Math.sin(a) * d; z = me.z + Math.cos(a) * d;
|
||||
if (!o.blocked || !o.blocked(x, z)) break;
|
||||
}
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: ladyTex[0], transparent: true, alphaTest: 0.5 }));
|
||||
sp.scale.set(LADY_H, LADY_H, 1);
|
||||
group.add(sp);
|
||||
mob = { sp, x, z, hp: 3, alive: true, hurt: 0, dead: 0 };
|
||||
placeMob();
|
||||
shout(GrannyKit.pick(GrannyKit.SHOUTS));
|
||||
}
|
||||
function mobFloor(x, z) { return o.floorAt ? o.floorAt(x, z) : 0; }
|
||||
function placeMob() { mob.sp.position.set(mob.x, mobFloor(mob.x, mob.z) + LADY_H / 2 - (mob.dead ? LADY_H * 0.3 : 0), mob.z); }
|
||||
function mobCenter() { return new THREE.Vector3(mob.x, mobFloor(mob.x, mob.z) + 0.8, mob.z); }
|
||||
// Straal tegen een staande cilinder (r 0,4 m, 1,6 m hoog) om de oma.
|
||||
function rayHitsMob(origin, dir, maxDist) {
|
||||
if (!mob || !mob.alive) return false;
|
||||
const c = mobCenter();
|
||||
const t = c.clone().sub(origin).dot(dir);
|
||||
if (t < 0 || t > maxDist) return false;
|
||||
const p = origin.clone().addScaledVector(dir, t);
|
||||
return Math.hypot(p.x - c.x, p.z - c.z) < 0.4 && Math.abs(p.y - c.y) < 0.8;
|
||||
}
|
||||
const gibTex = GrannyKit.GIBS.map(rows => {
|
||||
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.GIB_PAL, { size: 8 }));
|
||||
t.magFilter = t.minFilter = THREE.NearestFilter;
|
||||
t.generateMipmaps = false;
|
||||
return t;
|
||||
});
|
||||
const bloodTex = canvasTex(32, (g) => {
|
||||
g.fillStyle = '#7a0a0a';
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const a = Math.random() * 6.28, d = Math.random() * 10, r = 2 + Math.random() * 5;
|
||||
g.beginPath(); g.arc(16 + Math.cos(a) * d, 16 + Math.sin(a) * d, r, 0, 6.28); g.fill();
|
||||
}
|
||||
});
|
||||
bloodTex.magFilter = THREE.NearestFilter;
|
||||
const bloodMat = decalMat(bloodTex);
|
||||
function spawnGibs(center, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Vooral vlees, plus een paar herkenbare stukken.
|
||||
const k = i < count * 0.5 ? Math.floor(Math.random() * 2) : Math.floor(Math.random() * GrannyKit.GIBS.length);
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: gibTex[k], alphaTest: 0.5 }));
|
||||
const sz = 0.18 + Math.random() * 0.14;
|
||||
sp.scale.set(sz, sz, 1);
|
||||
sp.position.copy(center).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.8, (Math.random() - 0.5) * 0.4));
|
||||
const v = new THREE.Vector3((Math.random() - 0.5) * 9, 2 + Math.random() * 6, (Math.random() - 0.5) * 9);
|
||||
group.add(sp);
|
||||
debris.push({ mesh: sp, v, spin: new THREE.Vector3((Math.random() - 0.5) * 20, 0, 0), rest: false, gib: true });
|
||||
}
|
||||
// Plas onder de plek des onheils.
|
||||
bloodSplat(center.x, center.z, 1.4);
|
||||
trimDebris();
|
||||
}
|
||||
function bloodSplat(x, z, size) {
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), bloodMat);
|
||||
m.rotation.set(-Math.PI / 2, 0, Math.random() * 6.28);
|
||||
m.position.set(x, mobFloor(x, z) + 0.006, z);
|
||||
group.add(m);
|
||||
decals.push(m);
|
||||
}
|
||||
function trimDebris() {
|
||||
while (debris.length > 200) {
|
||||
const old = debris.shift();
|
||||
group.remove(old.mesh);
|
||||
if (old.mesh.isSprite) old.mesh.material.dispose(); else old.mesh.geometry.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function damageMob(n, gib) {
|
||||
if (!mob || !mob.alive) return;
|
||||
mob.hp -= n;
|
||||
mob.hurt = 0.15;
|
||||
spawnBlast(mobCenter(), 0.4, 0.2);
|
||||
if (mob.hp > 0) { bang(0.2, 900, 0.3); return; }
|
||||
mob.alive = false;
|
||||
mob.dead = 0.001;
|
||||
mob.sp.material.map = ladyHurtTex;
|
||||
mob.sp.material.rotation = Math.PI / 2; // omgevallen
|
||||
if (gib) { mob.sp.visible = false; mob.dead = 2.4; spawnGibs(mobCenter(), 22); }
|
||||
else spawnGibs(mobCenter(), 5);
|
||||
setKills(announcer.kill());
|
||||
shout(GrannyKit.pick(GrannyKit.DEATH_SHOUTS));
|
||||
spawnTimer = 1;
|
||||
}
|
||||
function updateMob(t) {
|
||||
if (!mob) { spawnTimer -= t; if (spawnTimer <= 0) spawnMob(); return; }
|
||||
if (!mob.alive) {
|
||||
mob.dead += t;
|
||||
mob.sp.material.opacity = Math.max(0, 1 - (mob.dead - 1.5));
|
||||
mob.sp.material.transparent = true;
|
||||
placeMob();
|
||||
if (mob.dead > 2.5) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
|
||||
return;
|
||||
}
|
||||
const me = camera.position;
|
||||
const dx = me.x - mob.x, dz = me.z - mob.z, dist = Math.hypot(dx, dz);
|
||||
if (dist > 0.9) {
|
||||
const v = 1.3 * t / dist;
|
||||
const nx = mob.x + dx * v, nz = mob.z + dz * v;
|
||||
if (!o.blocked || !o.blocked(nx, nz)) { mob.x = nx; mob.z = nz; }
|
||||
else if (!o.blocked(nx, mob.z)) mob.x = nx;
|
||||
else if (!o.blocked(mob.x, nz)) mob.z = nz;
|
||||
animT += t;
|
||||
} else {
|
||||
attackTimer -= t;
|
||||
if (attackTimer <= 0) {
|
||||
attackTimer = 1.2;
|
||||
bang(0.1, 400, 0.6);
|
||||
ouch.classList.remove('show'); void ouch.offsetWidth; ouch.classList.add('show');
|
||||
announcer.hurt();
|
||||
shout(GrannyKit.pick(GrannyKit.HIT_SHOUTS));
|
||||
}
|
||||
}
|
||||
mob.hurt -= t;
|
||||
mob.sp.material.map = mob.hurt > 0 ? ladyHurtTex : ladyTex[Math.floor(animT * 4) % 2];
|
||||
placeMob();
|
||||
}
|
||||
|
||||
const music = GrannyKit.createMusic();
|
||||
const sfx = GrannyKit.createSfx();
|
||||
const bang = sfx.bang;
|
||||
|
||||
// --- Raken ---
|
||||
const ray = new THREE.Raycaster();
|
||||
function hitScan(origin, dir, far) {
|
||||
ray.set(origin, dir);
|
||||
ray.far = far;
|
||||
const hits = ray.intersectObjects(o.targets().filter(t => t && t.visible), true);
|
||||
return hits.find(h => h.face && h.object.isMesh && isVisible(h.object)) || null;
|
||||
}
|
||||
function isVisible(obj) {
|
||||
for (let p = obj; p; p = p.parent) if (!p.visible) return false;
|
||||
return true;
|
||||
}
|
||||
function addDecal(hit, mat, size) {
|
||||
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
||||
if (n.dot(ray.ray.direction) > 0) n.negate(); // altijd de kant waar je vandaan schiet
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), mat);
|
||||
m.position.copy(hit.point).addScaledVector(n, 0.004);
|
||||
m.lookAt(hit.point.clone().add(n));
|
||||
m.rotateZ(Math.random() * Math.PI * 2);
|
||||
group.add(m);
|
||||
decals.push(m);
|
||||
if (decals.length > MAX_DECALS) { const old = decals.shift(); group.remove(old); old.geometry.dispose(); }
|
||||
}
|
||||
|
||||
const tmpDir = new THREE.Vector3();
|
||||
function fire() {
|
||||
if (cooldown > 0) return;
|
||||
camera.getWorldDirection(tmpDir);
|
||||
// Lichte spreiding, het blijft een configurator.
|
||||
tmpDir.x += (Math.random() - 0.5) * 0.01; tmpDir.y += (Math.random() - 0.5) * 0.01;
|
||||
tmpDir.normalize();
|
||||
flash.visible = true;
|
||||
flash.material.rotation = Math.random() * 6;
|
||||
gunLight.intensity = 2;
|
||||
if (weapon === 0) {
|
||||
cooldown = 0.18; recoil = 1;
|
||||
bang(0.15, 3000, 0.5);
|
||||
const hit = hitScan(camera.position, tmpDir, 200);
|
||||
if (rayHitsMob(camera.position, tmpDir, hit ? hit.distance : 200)) damageMob(1);
|
||||
else if (hit) {
|
||||
addDecal(hit, holeMat, 0.05 + Math.random() * 0.03);
|
||||
spawnBlast(hit.point, 0.15, 0.12);
|
||||
}
|
||||
} else {
|
||||
cooldown = 0.8; recoil = 2.5;
|
||||
bang(0.5, 600, 0.6);
|
||||
const r = new THREE.Group();
|
||||
r.add(box(0.06, 0.06, 0.3, olive, 0, 0, 0));
|
||||
const tail = new THREE.Sprite(fireMat);
|
||||
tail.scale.set(0.3, 0.3, 1); tail.position.z = 0.2;
|
||||
r.add(tail);
|
||||
r.position.copy(camera.position).addScaledVector(tmpDir, 0.6);
|
||||
r.lookAt(r.position.clone().add(tmpDir));
|
||||
group.add(r);
|
||||
rockets.push({ mesh: r, dir: tmpDir.clone(), speed: 14, life: 6 });
|
||||
}
|
||||
}
|
||||
|
||||
function spawnBlast(pos, size, life) {
|
||||
const s = new THREE.Sprite(fireMat.clone());
|
||||
s.position.copy(pos);
|
||||
s.scale.set(size, size, 1);
|
||||
group.add(s);
|
||||
blasts.push({ s, size, life, t: 0 });
|
||||
}
|
||||
|
||||
function explode(hit, point, normal) {
|
||||
bang(1.2, 300, 1);
|
||||
if (hit) {
|
||||
addDecal(hit, scorchMat, 1.2 + Math.random() * 0.6);
|
||||
point = hit.point;
|
||||
normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
||||
if (normal.dot(ray.ray.direction) > 0) normal.negate();
|
||||
}
|
||||
spawnBlast(point, 2.5, 0.5);
|
||||
const light = new THREE.PointLight(0xff8833, 6, 12);
|
||||
light.position.copy(point);
|
||||
group.add(light);
|
||||
blasts.push({ light, life: 0.5, t: 0 });
|
||||
if (mob && mob.alive && mobCenter().distanceTo(point) < 3) damageMob(99, true);
|
||||
if (!hit) return; // direct op de oma: alleen gibs, geen stenen
|
||||
const n = normal;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const sz = 0.05 + Math.random() * 0.12;
|
||||
const d = new THREE.Mesh(new THREE.BoxGeometry(sz * 2, sz, sz), debrisMat);
|
||||
d.position.copy(hit.point).addScaledVector(n, 0.1);
|
||||
d.rotation.set(Math.random() * 6, Math.random() * 6, 0);
|
||||
const v = n.clone().multiplyScalar(2 + Math.random() * 4).add(new THREE.Vector3((Math.random() - 0.5) * 6, Math.random() * 5, (Math.random() - 0.5) * 6));
|
||||
group.add(d);
|
||||
debris.push({ mesh: d, v, spin: new THREE.Vector3(Math.random() * 10, Math.random() * 10, 0), rest: false });
|
||||
}
|
||||
trimDebris();
|
||||
}
|
||||
|
||||
// --- Invoer ---
|
||||
function onClick(e) {
|
||||
if (!active) return;
|
||||
if (document.pointerLockElement !== domElement) { domElement.requestPointerLock && domElement.requestPointerLock(); return; }
|
||||
if (e.button === 0) fire();
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!active || document.pointerLockElement !== domElement) return;
|
||||
walk.look(-e.movementX * 0.0025, -e.movementY * 0.0025);
|
||||
}
|
||||
function onWheel(e) {
|
||||
if (!active) return;
|
||||
e.preventDefault();
|
||||
selectWeapon(weapon + (e.deltaY > 0 ? 1 : -1));
|
||||
}
|
||||
function onKey(e) {
|
||||
if (!active || e.ctrlKey) return;
|
||||
if (e.key === '1') selectWeapon(0);
|
||||
if (e.key === '2') selectWeapon(1);
|
||||
}
|
||||
domElement.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
function clear() {
|
||||
if (mob) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
|
||||
shoutLabel.classList.remove('show');
|
||||
[...decals, ...debris.map(d => d.mesh), ...rockets.map(r => r.mesh), ...blasts.map(b => b.s || b.light)].forEach(m => group.remove(m));
|
||||
decals.length = debris.length = rockets.length = blasts.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
get active() { return active; },
|
||||
start() {
|
||||
active = true;
|
||||
scene.add(group);
|
||||
hud.classList.add('show');
|
||||
announcer.reset();
|
||||
setKills(0);
|
||||
spawnTimer = 1.5;
|
||||
music.play();
|
||||
},
|
||||
stop() {
|
||||
active = false;
|
||||
if (document.pointerLockElement === domElement) document.exitPointerLock();
|
||||
clear();
|
||||
scene.remove(group);
|
||||
hud.classList.remove('show');
|
||||
music.stop();
|
||||
announcer.stop();
|
||||
},
|
||||
update(dt) {
|
||||
if (!active) return;
|
||||
const t = Math.min(dt, 0.05);
|
||||
cooldown -= t;
|
||||
// Wapen volgt de camera, met wat terugslag.
|
||||
recoil = Math.max(0, recoil - t * 8);
|
||||
viewModel.position.copy(camera.position);
|
||||
viewModel.quaternion.copy(camera.quaternion);
|
||||
viewModel.translateX(0.18); viewModel.translateY(-0.16 + recoil * 0.01); viewModel.translateZ(-0.25 + recoil * 0.03);
|
||||
viewModel.rotateX(recoil * 0.08);
|
||||
gunLight.position.copy(camera.position);
|
||||
if (flash.visible && cooldown < (weapon === 0 ? 0.13 : 0.72)) flash.visible = false;
|
||||
gunLight.intensity = Math.max(0, gunLight.intensity - t * 30);
|
||||
announcer.update(t);
|
||||
updateMob(t);
|
||||
if (shoutTimer > 0 && (shoutTimer -= t) <= 0) shoutLabel.classList.remove('show');
|
||||
|
||||
for (let i = rockets.length - 1; i >= 0; i--) {
|
||||
const r = rockets[i];
|
||||
r.life -= t;
|
||||
const stepLen = r.speed * t;
|
||||
r.speed += 10 * t;
|
||||
const hit = hitScan(r.mesh.position, r.dir, stepLen);
|
||||
if (rayHitsMob(r.mesh.position, r.dir, stepLen + 0.3)) {
|
||||
explode(null, mobCenter(), r.dir.clone().negate());
|
||||
group.remove(r.mesh);
|
||||
rockets.splice(i, 1);
|
||||
} else if (hit || r.life <= 0) {
|
||||
if (hit) explode(hit);
|
||||
group.remove(r.mesh);
|
||||
rockets.splice(i, 1);
|
||||
} else {
|
||||
r.mesh.position.addScaledVector(r.dir, stepLen);
|
||||
if (Math.random() < 0.5) spawnBlast(r.mesh.position.clone().addScaledVector(r.dir, -0.2), 0.2, 0.3);
|
||||
}
|
||||
}
|
||||
for (let i = blasts.length - 1; i >= 0; i--) {
|
||||
const b = blasts[i];
|
||||
b.t += t;
|
||||
const k = b.t / b.life;
|
||||
if (b.s) { b.s.scale.setScalar(b.size * (0.6 + k)); b.s.material.opacity = 1 - k; }
|
||||
if (b.light) b.light.intensity = 6 * (1 - k);
|
||||
if (k >= 1) { group.remove(b.s || b.light); if (b.s) b.s.material.dispose(); blasts.splice(i, 1); }
|
||||
}
|
||||
for (const d of debris) {
|
||||
if (d.rest) continue;
|
||||
d.v.y -= 9.81 * t;
|
||||
const next = d.mesh.position.clone().addScaledVector(d.v, t);
|
||||
const floor = (o.floorAt ? o.floorAt(next.x, next.z) : 0) + (d.gib ? d.mesh.scale.y * 0.35 : 0.03);
|
||||
if (next.y <= floor && d.v.y < 0) {
|
||||
next.y = floor;
|
||||
d.v.multiplyScalar(0.4); d.v.y = -d.v.y * 0.5; d.spin.multiplyScalar(0.5);
|
||||
if (d.gib && !d.splat) { d.splat = true; bloodSplat(next.x, next.z, 0.3 + Math.random() * 0.3); }
|
||||
if (d.v.length() < 0.3) d.rest = true;
|
||||
}
|
||||
d.mesh.position.copy(next);
|
||||
if (d.mesh.isSprite) d.mesh.material.rotation += d.spin.x * t;
|
||||
else { d.mesh.rotation.x += d.spin.x * t; d.mesh.rotation.y += d.spin.y * t; }
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
this.stop();
|
||||
domElement.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
domElement.removeEventListener('wheel', onWheel);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
hud.remove();
|
||||
sfx.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
+10
-31
@@ -4,33 +4,20 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/skybox.js — omgeving voor de 3D-weergaven (Ontwerp / Dag / Nacht).
|
||||
// Een panorama (assets/skybox.webp of skybox-night.webp) wordt op de grond
|
||||
// geprojecteerd, zodat het object op de grond staat; onder het object ligt
|
||||
// een ronde tegelvloer (assets/tiles.jpg) met een witte opstaande rand, groot
|
||||
// genoeg voor het hele object. Vloer en rand reageren op het licht en vangen
|
||||
// de schaduw op. Nacht: omgevingslicht op NIGHT_FACTOR (lampen blijven 100%).
|
||||
//
|
||||
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT; wij draaien
|
||||
// r128): een binnenstebuiten bol waarvan de onderhelft plat gedrukt wordt op
|
||||
// een vloer op CAMERA_HEIGHT_M onder het midden van de bol.
|
||||
//
|
||||
// Vereist een webserver (texturen via TextureLoader). Plain script.
|
||||
// ============================================================
|
||||
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT); wij draaien r128.
|
||||
|
||||
window.Skybox = (() => {
|
||||
const PREF_KEY = 'tectum.sky';
|
||||
const MODES = ['none', 'day', 'night'];
|
||||
const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' };
|
||||
const TILES_URL = 'assets/tiles.jpg';
|
||||
const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven de grond
|
||||
const RADIUS_M = 60; // straal van de omgevingsbol
|
||||
const TILE_M = 2.4; // één tegeltextuur (1024 px) beslaat zoveel meter
|
||||
const MARGIN_M = 1.5; // vrije rand tussen object en opstaande rand
|
||||
const SIZE_FACTOR = 1.2; // tegelvloer 20% ruimer dan object + marge
|
||||
const CAMERA_HEIGHT_M = 1.7;
|
||||
const RADIUS_M = 60;
|
||||
const TILE_M = 2.4;
|
||||
const MARGIN_M = 1.5;
|
||||
const SIZE_FACTOR = 1.2;
|
||||
const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025;
|
||||
const NIGHT_FACTOR = 0.15; // omgevingslicht 's nachts
|
||||
const NIGHT_FACTOR = 0.15;
|
||||
const SUN_OFFSET = { x: 8, y: 14, z: 6 };
|
||||
|
||||
const cache = {};
|
||||
@@ -51,7 +38,6 @@ window.Skybox = (() => {
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
v.fromBufferAttribute(pos, i);
|
||||
if (v.y < 0) {
|
||||
// Onderhelft plat op y = -h; vloeiende overgang naar de bol.
|
||||
const f = v.y < y1 ? -h / v.y : (1 - v.y * v.y / (3 * y1 * y1));
|
||||
v.multiplyScalar(f);
|
||||
pos.setXYZ(i, v.x, v.y, v.z);
|
||||
@@ -59,12 +45,11 @@ window.Skybox = (() => {
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false }));
|
||||
mesh.position.y = h - 0.01; // vlakke vloer net onder de tegelvloer
|
||||
mesh.position.y = h - 0.01;
|
||||
mesh.renderOrder = -1;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// Ronde tegelvloer (straal r) + witte opstaande rand erlangs.
|
||||
function buildTerrace(map, r) {
|
||||
const group = new THREE.Group();
|
||||
const tex = map.clone();
|
||||
@@ -86,13 +71,12 @@ window.Skybox = (() => {
|
||||
new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 })
|
||||
);
|
||||
rim.rotation.x = -Math.PI / 2; // extrusie omhoog
|
||||
rim.rotation.x = -Math.PI / 2;
|
||||
rim.castShadow = rim.receiveShadow = true;
|
||||
group.add(rim);
|
||||
return group;
|
||||
}
|
||||
|
||||
// Omgeving voor één scene: { group, setMode(mode), fitTo(object3D, groundY) }.
|
||||
function createEnvironment(scene) {
|
||||
const group = new THREE.Group();
|
||||
group.visible = false;
|
||||
@@ -124,7 +108,6 @@ window.Skybox = (() => {
|
||||
return {
|
||||
group,
|
||||
setMode(m) { mode = m; group.visible = m !== 'none'; showSky(); },
|
||||
// Midden en straal uit de omhullende van het object (horizontaal), vloer op groundY (m).
|
||||
fitTo(object3D, groundY) {
|
||||
if (!object3D) return;
|
||||
const box = new THREE.Box3().setFromObject(object3D);
|
||||
@@ -144,14 +127,11 @@ window.Skybox = (() => {
|
||||
} catch (e) { return 'none'; }
|
||||
}
|
||||
function remember(m) {
|
||||
try { localStorage.setItem(PREF_KEY, m); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||
try { localStorage.setItem(PREF_KEY, m); } catch (e) { }
|
||||
}
|
||||
|
||||
const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1;
|
||||
|
||||
// Omgeving toepassen: lucht/vloer, raster, camera niet onder de grond, zon
|
||||
// met schaduw, en het licht (nacht: NIGHT_FACTOR).
|
||||
// o: { env, grid, controls, sun, ambient, base: { ambient, sun }, shadowRoot }
|
||||
function apply(m, o) {
|
||||
const on = m !== 'none';
|
||||
o.env.setMode(m);
|
||||
@@ -177,7 +157,6 @@ window.Skybox = (() => {
|
||||
if (o.shadowRoot) o.shadowRoot.traverse(obj => { if (obj.isMesh) obj.castShadow = on; });
|
||||
}
|
||||
|
||||
// Keuzemenu in een 3D-werkbalk: Ontwerp (geen omgeving) / Dag / Nacht.
|
||||
const LABELS = { none: ['pencil-square', 'Ontwerp'], day: ['sun', 'Dag'], night: ['moon-stars', 'Nacht'] };
|
||||
function createToggle(toolbar, onChange) {
|
||||
let m = getMode();
|
||||
|
||||
+10
-27
@@ -4,18 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap
|
||||
// Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen,
|
||||
// inklapbare secties, schuiven, specificatie-kaarten en de 3-staps
|
||||
// weergave-cyclus (sneltoets T).
|
||||
// Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen.
|
||||
//
|
||||
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
|
||||
// punt, geen uitleg tussen haakjes (die gaat in een hint), eenheid rechts
|
||||
// van het veld. Bijvoorbeeld "Afstand min." i.p.v. "Lampen min. h.o.h.".
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
// Labels: zelfstandig naamwoord met hoofdletter, geen dubbele punt, uitleg in een hint, eenheid rechts.
|
||||
|
||||
window.UI = (() => {
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
@@ -26,7 +15,6 @@ window.UI = (() => {
|
||||
return `<div class="ui-field${o && o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o && o.hint)}</label><div class="ui-control">${control}</div></div>`;
|
||||
}
|
||||
|
||||
// Getal. o: { step, unit ('mm' standaard, '' = geen), hint, min, disabled }
|
||||
function num(id, label, value, o) {
|
||||
o = o || {};
|
||||
const unit = o.unit === undefined ? 'mm' : o.unit;
|
||||
@@ -35,8 +23,6 @@ window.UI = (() => {
|
||||
`<span class="ui-unit">${unit}</span>`, o);
|
||||
}
|
||||
|
||||
// Schuif met gekoppeld getal (voor maten met een logisch bereik, zoals
|
||||
// hoogtes en hellingen). o: { min, max, step, unit ('mm' standaard), hint }.
|
||||
// Het getal mag buiten het schuifbereik; het model klemt zelf.
|
||||
function range(id, label, value, o) {
|
||||
o = o || {};
|
||||
@@ -46,7 +32,6 @@ window.UI = (() => {
|
||||
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
|
||||
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
|
||||
}
|
||||
// fn(v) bij schuiven en typen; set(id, v) zet beide zonder event.
|
||||
function onRange(id, fn, after) {
|
||||
const n = document.getElementById(id), r = document.getElementById(id + '-r');
|
||||
if (!n || !r) return;
|
||||
@@ -64,13 +49,11 @@ window.UI = (() => {
|
||||
if (r) r.value = v;
|
||||
}
|
||||
|
||||
// Keuzelijst. options: { waarde: 'Label' }.
|
||||
function sel(id, label, options, value, o) {
|
||||
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
|
||||
return field(id, label, `<select id="${id}">${opts}</select><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
|
||||
// Kleurkiezer (waarde '#rrggbb'); onColor reageert pas bij loslaten.
|
||||
function color(id, label, value, o) {
|
||||
return field(id, label, `<input type="color" id="${id}" value="${value}"><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
@@ -79,7 +62,14 @@ window.UI = (() => {
|
||||
if (el) el.addEventListener('change', (e) => { fn(e.target.value); after && after(); });
|
||||
}
|
||||
|
||||
// Aan/uit-schakelaar.
|
||||
function text(id, label, value, o) {
|
||||
return field(id, label, `<input type="text" id="${id}" value="${esc(value || '')}"${o && o.placeholder ? ` placeholder="${esc(o.placeholder)}"` : ''}><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
function onText(id, fn, after) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', (e) => { fn(e.target.value); after && after(); });
|
||||
}
|
||||
|
||||
function toggle(id, label, checked, o) {
|
||||
return field(id, label,
|
||||
`<label class="ui-switch"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}><span></span></label><span class="ui-unit"></span>`, o);
|
||||
@@ -94,8 +84,6 @@ window.UI = (() => {
|
||||
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
|
||||
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
|
||||
|
||||
// Tabbladen. group = sleutel waaronder het actieve tabblad onthouden wordt
|
||||
// (bijv. 'woning', 'unit'); tabs = [{ key, label, icon, html }].
|
||||
const activeTabs = {};
|
||||
function tabs(group, list) {
|
||||
const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key;
|
||||
@@ -104,7 +92,6 @@ window.UI = (() => {
|
||||
${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
// Eenmalig per container: klik op een tab wisselt de pagina.
|
||||
function wireTabs(container) {
|
||||
if (container._uiTabsWired) return;
|
||||
container._uiTabsWired = true;
|
||||
@@ -118,14 +105,12 @@ window.UI = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Specificaties: kaart met regels [label, waarde, eenheid].
|
||||
function specCard(title, iconName, rows, total) {
|
||||
const row = ([label, value, unit]) =>
|
||||
`<div class="ui-spec-row"><span>${label}</span><span class="ui-spec-val">${value}${unit ? `<small>${unit}</small>` : ''}</span></div>`;
|
||||
return `<div class="ui-spec-card"><h2>${icon(iconName)}${title}</h2>${rows.map(row).join('')}${total ? `<div class="ui-spec-total">${row(total)}</div>` : ''}</div>`;
|
||||
}
|
||||
|
||||
// Invoer koppelen: onNum/onSel/onToggle(root, id, fn, after).
|
||||
function onNum(id, fn, after, o) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
@@ -143,8 +128,6 @@ window.UI = (() => {
|
||||
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
||||
}
|
||||
|
||||
// Weergave-cyclus 3D (sneltoets T): standaard -> 3D groot -> alleen de
|
||||
// render (ook het info-/specificatiedeel eronder weg) -> standaard.
|
||||
function cycle3D(el) {
|
||||
el.classList.remove('fullscreen-2d');
|
||||
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
|
||||
@@ -156,5 +139,5 @@ window.UI = (() => {
|
||||
el.classList.toggle('fullscreen-2d');
|
||||
}
|
||||
|
||||
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
|
||||
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
|
||||
})();
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/view-filter.js — gedeelde "Weergave"-dropdown met vinkjes voor de
|
||||
// 3D-weergaven (woning, wand, vloer/dak). Vervangt de losse Aan/Uit-knoppen.
|
||||
// Plain script (geen ES-module).
|
||||
//
|
||||
// const filter = createViewFilter(toolbarEl, [
|
||||
// { key: 'beplating', label: 'Beplating', checked: true }, ...
|
||||
// ], (state) => { ... }); // state = { beplating: true, ... }
|
||||
// ============================================================
|
||||
|
||||
function createViewFilter(toolbar, items, onChange) {
|
||||
const state = {};
|
||||
@@ -41,7 +32,6 @@ function createViewFilter(toolbar, items, onChange) {
|
||||
state[key] = e.target.checked;
|
||||
onChange(Object.assign({}, state));
|
||||
});
|
||||
// Klik buiten het menu sluit het (listener verdwijnt met het element).
|
||||
const closeOnOutside = () => {
|
||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
||||
menu.classList.remove('open');
|
||||
|
||||
@@ -4,30 +4,13 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/walk-controls.js — lopen in de 3D-weergave (first person).
|
||||
// W/S of pijl omhoog/omlaag vooruit/achteruit
|
||||
// A/D opzij stappen
|
||||
// pijl links/rechts draaien
|
||||
// Shift rennen
|
||||
// slepen met de muis rondkijken
|
||||
// Botsen (optioneel) en vloerhoogte komen van de aanroeper:
|
||||
// opts.blocked(x, z) -> true als je daar niet kunt staan (m); standaard nooit
|
||||
// opts.floorAt(x, z) -> hoogte van de ondergrond (m)
|
||||
// opts.eye -> ooghoogte boven de ondergrond (m, standaard 1,65)
|
||||
// Plain script (geen ES-module); gebruikt THREE.
|
||||
//
|
||||
// const walk = createWalkControls(camera, domElement, opts);
|
||||
// walk.start({ x, z, yaw }); walk.update(dt); walk.stop();
|
||||
// ============================================================
|
||||
|
||||
function createWalkControls(camera, domElement, opts) {
|
||||
const o = Object.assign({ eye: 1.65, speed: 1.68, run: 3.84, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {});
|
||||
const o = Object.assign({ eye: 1.65, speed: 2.1, run: 4.8, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {});
|
||||
const keys = new Set();
|
||||
let active = false;
|
||||
let x = 0, z = 0, yaw = 0, pitch = 0;
|
||||
let drag = null;
|
||||
let speedFactor = 1; // bijv. 1,3 in de shooter-modus; reset bij start()
|
||||
|
||||
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
|
||||
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
|
||||
@@ -61,7 +44,6 @@ function createWalkControls(camera, domElement, opts) {
|
||||
camera.lookAt(camera.position.clone().add(dir));
|
||||
}
|
||||
|
||||
// Stap met botsen: eerst de hele stap, anders per as (langs een muur glijden).
|
||||
function step(dx, dz) {
|
||||
if (!o.blocked(x + dx, z + dz)) { x += dx; z += dz; return; }
|
||||
if (!o.blocked(x + dx, z)) x += dx;
|
||||
@@ -72,17 +54,10 @@ function createWalkControls(camera, domElement, opts) {
|
||||
get active() { return active; },
|
||||
start(pose) {
|
||||
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
|
||||
speedFactor = 1;
|
||||
keys.clear();
|
||||
active = true;
|
||||
apply();
|
||||
},
|
||||
// Rondkijken van buitenaf (bijv. muis gevangen in core/shooter.js).
|
||||
set speedFactor(f) { speedFactor = f; },
|
||||
look(dYaw, dPitch) {
|
||||
yaw += dYaw;
|
||||
pitch = Math.max(-1.4, Math.min(1.4, pitch + dPitch));
|
||||
},
|
||||
stop() { active = false; keys.clear(); drag = null; },
|
||||
update(dt) {
|
||||
if (!active) return;
|
||||
@@ -92,9 +67,9 @@ function createWalkControls(camera, domElement, opts) {
|
||||
const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0);
|
||||
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
|
||||
if (f || r) {
|
||||
const v = (keys.has('shift') ? o.run : o.speed) * speedFactor * t / Math.hypot(f, r);
|
||||
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit
|
||||
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X)
|
||||
const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
|
||||
const fx = Math.sin(yaw), fz = Math.cos(yaw);
|
||||
const rx = -fz, rz = fx;
|
||||
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
|
||||
}
|
||||
apply();
|
||||
|
||||
Reference in New Issue
Block a user