Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a
Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege, Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und den Fassadenplaenen von EHLE Architekten. - tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node) - src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht - Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Begehen des Gartens: Maus/Tastatur am Desktop, Joystick und Wischen am Handy.
|
||||
* Der Spieler folgt dem Gelaende, kann Stufen bis 45 cm ersteigen und wird
|
||||
* von Mauern, Gebaeuden und Objekten aufgehalten.
|
||||
*/
|
||||
import { Euler, PerspectiveCamera, Vector3 } from 'three';
|
||||
|
||||
/** Achsenausgerichtetes oder gedrehtes Hindernis in der Draufsicht. */
|
||||
export interface Obstacle {
|
||||
cx: number;
|
||||
cz: number;
|
||||
hw: number;
|
||||
hd: number;
|
||||
/** Drehung um Y in Radiant. */
|
||||
rot: number;
|
||||
/** Hindernisse unterhalb dieser Hoehe kann man uebersteigen. */
|
||||
climbable?: boolean;
|
||||
}
|
||||
|
||||
export interface WalkerOptions {
|
||||
height: (x: number, z: number) => number;
|
||||
obstacles: Obstacle[];
|
||||
bounds: { x0: number; z0: number; x1: number; z1: number };
|
||||
eyeHeight?: number;
|
||||
radius?: number;
|
||||
maxStep?: number;
|
||||
}
|
||||
|
||||
const SPEED_WALK = 1.65;
|
||||
const SPEED_RUN = 3.6;
|
||||
|
||||
export class Walker {
|
||||
readonly camera: PerspectiveCamera;
|
||||
private readonly opts: Required<WalkerOptions>;
|
||||
private readonly euler = new Euler(0, 0, 0, 'YXZ');
|
||||
private readonly keys = new Set<string>();
|
||||
private readonly pos = new Vector3();
|
||||
private velocityY = 0;
|
||||
private grounded = true;
|
||||
private joystick = { x: 0, y: 0, active: false };
|
||||
private lookDelta = { x: 0, y: 0 };
|
||||
private pointerLocked = false;
|
||||
private enabled = true;
|
||||
|
||||
/** Wird gesetzt, wenn der Nutzer die Maus gefangen hat oder wieder frei ist. */
|
||||
onPointerLockChange?: (locked: boolean) => void;
|
||||
|
||||
constructor(camera: PerspectiveCamera, options: WalkerOptions) {
|
||||
this.camera = camera;
|
||||
this.opts = {
|
||||
eyeHeight: 1.68,
|
||||
radius: 0.32,
|
||||
maxStep: 0.45,
|
||||
...options,
|
||||
} as Required<WalkerOptions>;
|
||||
}
|
||||
|
||||
get position(): Vector3 { return this.pos; }
|
||||
get yawDegrees(): number { return (this.euler.y * 180) / Math.PI; }
|
||||
|
||||
setEnabled(on: boolean) { this.enabled = on; }
|
||||
|
||||
teleport(x: number, z: number, yawDeg: number) {
|
||||
this.pos.set(x, this.opts.height(x, z) + this.opts.eyeHeight, z);
|
||||
this.euler.set(0, (-yawDeg * Math.PI) / 180, 0);
|
||||
this.velocityY = 0;
|
||||
this.applyCamera();
|
||||
}
|
||||
|
||||
// -- Eingaben -------------------------------------------------------------
|
||||
|
||||
attach(dom: HTMLElement) {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Tab') return;
|
||||
this.keys.add(e.code);
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
|
||||
});
|
||||
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
|
||||
window.addEventListener('blur', () => this.keys.clear());
|
||||
|
||||
dom.addEventListener('click', () => {
|
||||
if (!this.enabled || this.pointerLocked || isTouch()) return;
|
||||
// In eingebetteten Vorschauen ist Pointer Lock gesperrt - kein Grund abzustuerzen.
|
||||
Promise.resolve(dom.requestPointerLock()).catch(() => {});
|
||||
});
|
||||
document.addEventListener('pointerlockchange', () => {
|
||||
this.pointerLocked = document.pointerLockElement === dom;
|
||||
this.onPointerLockChange?.(this.pointerLocked);
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.pointerLocked || !this.enabled) return;
|
||||
this.lookDelta.x -= e.movementX * 0.0022;
|
||||
this.lookDelta.y -= e.movementY * 0.0022;
|
||||
});
|
||||
|
||||
// Wischen zum Umsehen (rechte Bildschirmhaelfte bleibt fuer den Blick frei)
|
||||
let touchId: number | null = null;
|
||||
let last = { x: 0, y: 0 };
|
||||
dom.addEventListener('touchstart', (e) => {
|
||||
for (const t of Array.from(e.changedTouches)) {
|
||||
if (touchId === null && t.clientX > window.innerWidth * 0.35) {
|
||||
touchId = t.identifier;
|
||||
last = { x: t.clientX, y: t.clientY };
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
dom.addEventListener('touchmove', (e) => {
|
||||
for (const t of Array.from(e.changedTouches)) {
|
||||
if (t.identifier !== touchId) continue;
|
||||
this.lookDelta.x -= (t.clientX - last.x) * 0.005;
|
||||
this.lookDelta.y -= (t.clientY - last.y) * 0.005;
|
||||
last = { x: t.clientX, y: t.clientY };
|
||||
}
|
||||
}, { passive: true });
|
||||
const end = (e: TouchEvent) => {
|
||||
for (const t of Array.from(e.changedTouches)) if (t.identifier === touchId) touchId = null;
|
||||
};
|
||||
dom.addEventListener('touchend', end, { passive: true });
|
||||
dom.addEventListener('touchcancel', end, { passive: true });
|
||||
}
|
||||
|
||||
setJoystick(x: number, y: number, active: boolean) {
|
||||
this.joystick = { x, y, active };
|
||||
}
|
||||
|
||||
// -- Bewegung -------------------------------------------------------------
|
||||
|
||||
update(dt: number) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.euler.y += this.lookDelta.x;
|
||||
this.euler.x += this.lookDelta.y;
|
||||
this.euler.x = Math.max(-1.4, Math.min(1.4, this.euler.x));
|
||||
this.lookDelta.x = 0;
|
||||
this.lookDelta.y = 0;
|
||||
|
||||
let forward = 0, strafe = 0;
|
||||
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) forward += 1;
|
||||
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) forward -= 1;
|
||||
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) strafe += 1;
|
||||
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) strafe -= 1;
|
||||
if (this.joystick.active) {
|
||||
forward += -this.joystick.y;
|
||||
strafe += this.joystick.x;
|
||||
}
|
||||
|
||||
const len = Math.hypot(forward, strafe);
|
||||
if (len > 1) { forward /= len; strafe /= len; }
|
||||
|
||||
const running = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight');
|
||||
const speed = (running ? SPEED_RUN : SPEED_WALK) * dt;
|
||||
const sin = Math.sin(this.euler.y), cos = Math.cos(this.euler.y);
|
||||
const dx = (-sin * forward + cos * strafe) * speed;
|
||||
const dz = (-cos * forward - sin * strafe) * speed;
|
||||
|
||||
this.move(dx, dz);
|
||||
|
||||
// Vertikal: dem Gelaende folgen, Absaetze weich abfangen
|
||||
const ground = this.opts.height(this.pos.x, this.pos.z) + this.opts.eyeHeight;
|
||||
if (this.pos.y > ground + 0.02) {
|
||||
this.velocityY -= 9.81 * dt;
|
||||
this.pos.y += this.velocityY * dt;
|
||||
if (this.pos.y <= ground) { this.pos.y = ground; this.velocityY = 0; this.grounded = true; }
|
||||
else this.grounded = false;
|
||||
} else {
|
||||
// sanftes Nachziehen beim Aufsteigen
|
||||
this.pos.y += (ground - this.pos.y) * Math.min(1, dt * 12);
|
||||
this.velocityY = 0;
|
||||
this.grounded = true;
|
||||
}
|
||||
|
||||
this.applyCamera();
|
||||
}
|
||||
|
||||
/** Bewegt den Spieler und loest Kollisionen achsenweise auf. */
|
||||
private move(dx: number, dz: number) {
|
||||
const tryAxis = (nx: number, nz: number): boolean => {
|
||||
const y0 = this.opts.height(this.pos.x, this.pos.z);
|
||||
const y1 = this.opts.height(nx, nz);
|
||||
if (this.grounded && y1 - y0 > this.opts.maxStep) return false;
|
||||
if (this.blocked(nx, nz)) return false;
|
||||
const b = this.opts.bounds;
|
||||
if (nx < b.x0 || nx > b.x1 || nz < b.z0 || nz > b.z1) return false;
|
||||
this.pos.x = nx;
|
||||
this.pos.z = nz;
|
||||
return true;
|
||||
};
|
||||
if (dx !== 0) tryAxis(this.pos.x + dx, this.pos.z);
|
||||
if (dz !== 0) tryAxis(this.pos.x, this.pos.z + dz);
|
||||
}
|
||||
|
||||
private blocked(x: number, z: number): boolean {
|
||||
const r = this.opts.radius;
|
||||
for (const o of this.opts.obstacles) {
|
||||
const dx = x - o.cx, dz = z - o.cz;
|
||||
const c = Math.cos(-o.rot), s = Math.sin(-o.rot);
|
||||
const lx = dx * c - dz * s;
|
||||
const lz = dx * s + dz * c;
|
||||
if (Math.abs(lx) < o.hw + r && Math.abs(lz) < o.hd + r) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private applyCamera() {
|
||||
this.camera.position.copy(this.pos);
|
||||
this.camera.quaternion.setFromEuler(this.euler);
|
||||
}
|
||||
}
|
||||
|
||||
export function isTouch(): boolean {
|
||||
return window.matchMedia('(pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
/** Baut ein Hindernis aus einer Strecke (Mauer) mit gegebener Dicke. */
|
||||
export function obstacleFromSegment(a: [number, number], b: [number, number], thickness: number): Obstacle {
|
||||
const dx = b[0] - a[0], dz = b[1] - a[1];
|
||||
const len = Math.hypot(dx, dz);
|
||||
return {
|
||||
cx: (a[0] + b[0]) / 2,
|
||||
cz: (a[1] + b[1]) / 2,
|
||||
hw: len / 2,
|
||||
hd: thickness / 2,
|
||||
rot: Math.atan2(dz, dx),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user