Nordgrenze zum Nachbarn, Torbogen, einfacher Rundgang
Deploy App / deploy (push) Successful in 27s

Nordgrenze
- Sitzmauer und Travertinmauer waren vertauscht. Der Plan zeichnet aussen,
  auf der Terrasse der Nachbarn, die "Sitzmauer" (0.55 tief, rund 40 cm
  hoch: Terrasse -1.77, Krone -1.35) und direkt dahinter die
  "Travertinmauer", die jetzt bis auf das Niveau der Nachbarn hinunter-
  reicht - sie sehen Naturstein statt Beton.
- Terrasse der Nachbarn fest auf -1.77.
- Schotterwiese (Flaechen 4 und 5) eben: quer kein Hang mehr, laengs folgt
  sie der Feuerwehrzufahrt. Unter der Ligusterhecke fassen Winkelelemente
  den Garten der Nachbarn, so wie der Plan sie dort beschriftet. Wiesen mit
  fester Ebene werden jetzt plan gerendert statt dem Raster zu folgen.
- Eingang fuer die Nachbarn: Travertinstufe in der Luecke zwischen Hecke
  und Mauer ("Stufe" im Plan), darueber ein Torbogen aus Cortenstahl mit
  Kletterrose. Kein Tor.

Rundgang
- Klick auf den Boden laeuft dorthin, Ziehen dreht den Blick. Kein
  Mausfang mehr.
- Weiches Anfahren und Bremsen, gedaempftes Nachfuehren bei Stufen statt
  Fall-und-Aufprall, Rutschen an Hindernissen.
- Travertinmauern mit fester Krone sind begehbar; man steigt ueber die
  Sitzmauer vom Sitzplatz auf den Rasen, statt springen zu muessen.
- Gartentor oeffnet sich beim Naehertreten und schliesst hinter einem, E
  uebersteuert. Schwelle unter dem Tor schliesst die Luecke zum Podest.
- Blickrichtung blieb beim Wechsel Uebersicht/Rundgang nicht erhalten
  (Vorzeichenfehler in yawDegrees).
- mergeGeometries loest indizierte Geometrien vorher auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 09:50:44 +02:00
co-authored by Claude Opus 5
parent 866bedf64d
commit 1ac99b1660
11 changed files with 469 additions and 112 deletions
+166 -57
View File
@@ -1,7 +1,16 @@
/**
* Begehen des Gartens: Maus/Tastatur am Desktop, Joystick und Wischen am Handy.
* Der Spieler folgt dem Gelaende, kann Stufen bis 45 cm ersteigen, mit der
* Leertaste springen und wird von Mauern, Gebaeuden und Objekten aufgehalten.
* Begehen des Gartens.
*
* Bewusst einfach gehalten:
* - Umsehen: mit der Maus ziehen (am Handy rechts wischen). Kein Mausfang,
* der Mauszeiger bleibt frei fuer die Bedienelemente.
* - Gehen: W A S D oder Pfeiltasten, am Handy der Joystick - oder einfach
* auf den Boden klicken bzw. tippen, dann laeuft man dorthin.
* - Leertaste springt.
*
* Damit es sich nicht ruppig anfuehlt, beschleunigt und bremst der Spieler
* weich, folgt Stufen und Rampen gedaempft statt sprunghaft und rutscht an
* Hindernissen entlang, statt haengen zu bleiben.
*/
import { Euler, PerspectiveCamera, Vector3 } from 'three';
@@ -13,115 +22,163 @@ export interface Obstacle {
hd: number;
/** Drehung um Y in Radiant. */
rot: number;
/** Hindernisse unterhalb dieser Hoehe kann man uebersteigen. */
climbable?: boolean;
/** Voruebergehend durchlaessig, etwa ein offenes Tor. */
disabled?: boolean;
}
export interface WalkerOptions {
height: (x: number, z: number) => number;
obstacles: Obstacle[];
bounds: { x0: number; z0: number; x1: number; z1: number };
/** Liefert den Bodenpunkt unter einer Bildschirmposition, fuer Klick-zum-Gehen. */
pick?: (clientX: number, clientY: number) => { x: number; z: number } | null;
eyeHeight?: number;
radius?: number;
maxStep?: number;
}
const SPEED_WALK = 1.65;
const SPEED_WALK = 1.7;
const SPEED_RUN = 3.6;
/** Wie schnell die Geschwindigkeit dem Wunsch folgt (1/s). */
const ACCEL = 9;
/** Absprunggeschwindigkeit: reicht fuer rund 55 cm Sprunghoehe. */
const JUMP = 3.3;
const GRAVITY = 9.81;
/** Tiefer als das laeuft man nicht einfach hinunter, etwa von einer Stuetzmauer. */
const MAX_DROP = 1.2;
/** Ab so viel Hoehenunterschied faellt man, darunter wird weich nachgefuehrt. */
const FALL_FROM = 0.6;
export class Walker {
readonly camera: PerspectiveCamera;
private readonly opts: Required<WalkerOptions>;
private readonly opts: Required<Omit<WalkerOptions, 'pick'>> & Pick<WalkerOptions, 'pick'>;
private readonly euler = new Euler(0, 0, 0, 'YXZ');
private readonly keys = new Set<string>();
private readonly pos = new Vector3();
private vx = 0;
private vz = 0;
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;
/** Ziel fuer Klick-zum-Gehen. */
private target: { x: number; z: number } | null = null;
private stuck = 0;
/** Wird gesetzt, wenn der Nutzer die Maus gefangen hat oder wieder frei ist. */
onPointerLockChange?: (locked: boolean) => void;
/** Wird bei jedem neuen Gehziel aufgerufen (null = kein Ziel mehr). */
onTarget?: (t: { x: number; z: number } | null) => void;
constructor(camera: PerspectiveCamera, options: WalkerOptions) {
this.camera = camera;
this.opts = {
eyeHeight: 1.68,
radius: 0.32,
radius: 0.28,
maxStep: 0.45,
...options,
} as Required<WalkerOptions>;
} as Walker['opts'];
}
get position(): Vector3 { return this.pos; }
get yawDegrees(): number { return (this.euler.y * 180) / Math.PI; }
get yawDegrees(): number { return (-this.euler.y * 180) / Math.PI; }
setEnabled(on: boolean) { this.enabled = on; }
setEnabled(on: boolean) {
this.enabled = on;
if (!on) this.setTarget(null);
}
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.vx = this.vz = this.velocityY = 0;
this.grounded = true;
this.setTarget(null);
this.applyCamera();
}
private setTarget(t: { x: number; z: number } | null) {
this.target = t;
this.stuck = 0;
this.onTarget?.(t);
}
// -- Eingaben -------------------------------------------------------------
attach(dom: HTMLElement) {
window.addEventListener('keydown', (e) => {
if (e.code === 'Tab') return;
if (e.code === 'Tab' || e.target instanceof HTMLInputElement) 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(() => {});
// Maus: ziehen = umsehen, kurzer Klick = dorthin gehen.
let down: { x: number; y: number; id: number } | null = null;
let dragged = false;
dom.addEventListener('pointerdown', (e) => {
if (!this.enabled || e.pointerType !== 'mouse' || e.button !== 0) return;
down = { x: e.clientX, y: e.clientY, id: e.pointerId };
dragged = false;
dom.setPointerCapture(e.pointerId);
});
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;
dom.addEventListener('pointermove', (e) => {
if (!down || e.pointerId !== down.id) return;
if (!dragged && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) dragged = true;
if (dragged) {
this.lookDelta.x += e.movementX * 0.0042;
this.lookDelta.y += e.movementY * 0.0042;
dom.style.cursor = 'grabbing';
}
});
const up = (e: PointerEvent) => {
if (!down || e.pointerId !== down.id) return;
if (!dragged) this.walkTo(e.clientX, e.clientY);
down = null;
dom.style.cursor = '';
};
dom.addEventListener('pointerup', up);
dom.addEventListener('pointercancel', () => { down = null; dom.style.cursor = ''; });
// Wischen zum Umsehen (rechte Bildschirmhaelfte bleibt fuer den Blick frei)
// Touch: rechts wischen = umsehen, tippen = dorthin gehen
let touchId: number | null = null;
let last = { x: 0, y: 0 };
let start = { x: 0, y: 0, t: 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 };
start = { x: t.clientX, y: t.clientY, t: performance.now() };
}
}
}, { 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;
this.lookDelta.x += (t.clientX - last.x) * 0.006;
this.lookDelta.y += (t.clientY - last.y) * 0.006;
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;
for (const t of Array.from(e.changedTouches)) {
if (t.identifier !== touchId) continue;
touchId = null;
const moved = Math.hypot(t.clientX - start.x, t.clientY - start.y);
if (moved < 10 && performance.now() - start.t < 350) this.walkTo(t.clientX, t.clientY);
}
};
dom.addEventListener('touchend', end, { passive: true });
dom.addEventListener('touchcancel', end, { passive: true });
}
private walkTo(clientX: number, clientY: number) {
if (!this.enabled || !this.opts.pick) return;
const hit = this.opts.pick(clientX, clientY);
if (hit) this.setTarget(hit);
}
setJoystick(x: number, y: number, active: boolean) {
this.joystick = { x, y, active };
}
@@ -131,9 +188,11 @@ export class Walker {
update(dt: number) {
if (!this.enabled) return;
// Blick: man zieht die Szene - wie in der Uebersicht und bei Street View.
// Nach rechts ziehen dreht den Blick nach links, nach unten ziehen hebt ihn.
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.euler.x = Math.max(-1.3, Math.min(1.3, this.euler.x));
this.lookDelta.x = 0;
this.lookDelta.y = 0;
@@ -146,17 +205,49 @@ export class Walker {
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 speed = running ? SPEED_RUN : SPEED_WALK;
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;
let wx = (-sin * forward + cos * strafe) * speed;
let wz = (-cos * forward - sin * strafe) * speed;
this.move(dx, dz);
// Klick-zum-Gehen: nur, solange nicht von Hand gesteuert wird
if (len > 0.05) {
if (this.target) this.setTarget(null);
} else if (this.target) {
const tx = this.target.x - this.pos.x, tz = this.target.z - this.pos.z;
const dist = Math.hypot(tx, tz);
if (dist < 0.25) {
this.setTarget(null);
} else {
// kurz vor dem Ziel sanft auslaufen
const v = SPEED_WALK * Math.min(1, dist / 0.8);
wx = (tx / dist) * v;
wz = (tz / dist) * v;
// Blick dreht langsam in die Gehrichtung
const want = Math.atan2(-tx, -tz);
let d = want - this.euler.y;
d = Math.atan2(Math.sin(d), Math.cos(d));
this.euler.y += d * Math.min(1, dt * 3);
}
}
// Weich beschleunigen und bremsen
const k = Math.min(1, dt * ACCEL);
this.vx += (wx - this.vx) * k;
this.vz += (wz - this.vz) * k;
if (Math.abs(this.vx) < 1e-4 && Math.abs(this.vz) < 1e-4) { this.vx = 0; this.vz = 0; }
const before = { x: this.pos.x, z: this.pos.z };
this.move(this.vx * dt, this.vz * dt);
if (this.target) {
const moved = Math.hypot(this.pos.x - before.x, this.pos.z - before.z);
this.stuck = moved < 0.15 * SPEED_WALK * dt ? this.stuck + dt : 0;
if (this.stuck > 0.5) this.setTarget(null);
}
// Springen: nur vom Boden weg, danach uebernimmt die Schwerkraft.
if (this.keys.has('Space') && this.grounded) {
@@ -164,48 +255,66 @@ export class Walker {
this.grounded = false;
}
// Vertikal: dem Gelaende folgen, Absaetze weich abfangen
// Vertikal: kleine Hoehenwechsel gedaempft nachfuehren - hinauf wie
// hinunter. Nur bei grossen Abstuerzen oder im Sprung wirkt die Schwerkraft.
const ground = this.opts.height(this.pos.x, this.pos.z) + this.opts.eyeHeight;
if (!this.grounded || this.pos.y > ground + 0.02) {
if (this.grounded && this.pos.y - ground > FALL_FROM) this.grounded = false;
if (!this.grounded) {
this.velocityY -= GRAVITY * dt;
this.pos.y += this.velocityY * dt;
if (this.velocityY <= 0 && 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.pos.y += (ground - this.pos.y) * Math.min(1, dt * 11);
this.velocityY = 0;
this.grounded = true;
}
this.applyCamera();
}
/** Bewegt den Spieler und loest Kollisionen achsenweise auf. */
/**
* Bewegt den Spieler. Gelingt der ganze Schritt nicht, wird entlang der
* Achsen gerutscht - so gleitet man an Mauern entlang statt stehenzubleiben.
*/
private move(dx: number, dz: number) {
const tryAxis = (nx: number, nz: number): boolean => {
if (dx === 0 && dz === 0) return;
if (this.canStand(this.pos.x + dx, this.pos.z + dz)) {
this.pos.x += dx;
this.pos.z += dz;
return;
}
if (dx !== 0 && this.canStand(this.pos.x + dx, this.pos.z)) {
this.pos.x += dx;
this.vz *= 0.5;
} else if (dz !== 0 && this.canStand(this.pos.x, this.pos.z + dz)) {
this.pos.z += dz;
this.vx *= 0.5;
} else {
this.vx *= 0.3;
this.vz *= 0.3;
}
}
private canStand(nx: number, nz: number): boolean {
const b = this.opts.bounds;
if (nx < b.x0 || nx > b.x1 || nz < b.z0 || nz > b.z1) return false;
if (this.blocked(nx, nz)) return false;
if (this.grounded) {
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);
if (y1 - y0 > this.opts.maxStep) return false;
if (y0 - y1 > MAX_DROP) return false;
}
return true;
}
private blocked(x: number, z: number): boolean {
const r = this.opts.radius;
for (const o of this.opts.obstacles) {
if (o.disabled) continue;
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;