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:
+166
-57
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user