// src/game/ambientWalk.ts
/** Scene-local clock, never stored in story flags or saves. */
export const PLAZA_WALK = {
delayMs: 2500, minIntervalMs: 20000, maxIntervalMs: 60000,
cellWidth: 160, cellHeight: 304, baseline: 0.65,
} as const;
export const PLAZA_CHARACTERS = [
{ id: "walker", label: "Mann mit Aktentasche", frames: 8, durationMs: 30000, cycleMs: 960, height: 0.19 },
{ id: "beamtin", label: "Frau mit Aktenmappe", frames: 8, durationMs: 27000, cycleMs: 840, height: 0.18 },
{ id: "bote", label: "Bote mit Schultertasche", frames: 8, durationMs: 21000, cycleMs: 720, height: 0.19 },
{ id: "handwerker", label: "Handwerker mit Werkzeugkasten", frames: 8, durationMs: 34000, cycleMs: 1080, height: 0.19 },
{ id: "rentner", label: "Älterer Mann mit Stock", frames: 8, durationMs: 44000, cycleMs: 1450, height: 0.18 },
{ id: "schirmfrau", label: "Frau mit Regenschirm", frames: 6, durationMs: 29000, cycleMs: 1200, height: 0.23 },
] as const;
export type PlazaCharacter = typeof PLAZA_CHARACTERS[number];
export function createWalkVisit(random: () => number = Math.random) {
const order: PlazaCharacter[] = [...PLAZA_CHARACTERS];
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
const a = order[i], b = order[j];
if (a && b) { order[i] = b; order[j] = a; }
}
const schedule = order.map((character, index) => ({
character,
intervalMs: index === 0 ? PLAZA_WALK.delayMs :
PLAZA_WALK.minIntervalMs + random() * (PLAZA_WALK.maxIntervalMs - PLAZA_WALK.minIntervalMs),
}));
let lastNow: number | undefined;
let elapsed = 0;
let index = 0;
let entrance = PLAZA_WALK.delayMs as number;
return {
schedule,
sample(now: number, paused = false) {
if (lastNow !== undefined && !paused) elapsed += Math.max(0, now - lastNow);
lastNow = now;
while (index < schedule.length) {
const entry = schedule[index];
if (!entry || elapsed < entrance) return null;
const local = elapsed - entrance;
if (local < entry.character.durationMs) return {
character: entry.character,
progress: local / entry.character.durationMs,
frame: Math.floor(local / (entry.character.cycleMs / entry.character.frames)) % entry.character.frames,
};
index++;
const nextInterval = schedule[index]?.intervalMs ?? 0;
entrance += Math.max(entry.character.durationMs, nextInterval);
}
return null;
},
get finished() { return index >= schedule.length; },
};
}
// src/game/rainPolicy.ts
/** Touch devices stay excluded even in landscape or a wide virtual stage. */
export const RAIN_MEDIA_QUERY = "(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";
export function canAnimateRain(width: number, hasHover: boolean, finePointer: boolean, reducedMotion: boolean) {
return width >= 768 && hasHover && finePointer && !reducedMotion;
}
// src/game/ambientWalk.ts
/** Scene-local clock, never stored in story flags or saves. */
export const PLAZA_WALK = {
delayMs: 2500, minIntervalMs: 20000, maxIntervalMs: 60000,
cellWidth: 160, cellHeight: 304, baseline: 0.65,
} as const;
export const PLAZA_CHARACTERS = [
{ id: "walker", label: "Mann mit Aktentasche", frames: 8, durationMs: 30000, cycleMs: 960, height: 0.19 },
{ id: "beamtin", label: "Frau mit Aktenmappe", frames: 8, durationMs: 27000, cycleMs: 840, height: 0.18 },
{ id: "bote", label: "Bote mit Schultertasche", frames: 8, durationMs: 21000, cycleMs: 720, height: 0.19 },
{ id: "handwerker", label: "Handwerker mit Werkzeugkasten", frames: 8, durationMs: 34000, cycleMs: 1080, height: 0.19 },
{ id: "rentner", label: "Älterer Mann mit Stock", frames: 8, durationMs: 44000, cycleMs: 1450, height: 0.18 },
{ id: "schirmfrau", label: "Frau mit Regenschirm", frames: 6, durationMs: 29000, cycleMs: 1200, height: 0.23 },
] as const;
export type PlazaCharacter = typeof PLAZA_CHARACTERS[number];
export function createWalkVisit(random: () => number = Math.random) {
const order: PlazaCharacter[] = [...PLAZA_CHARACTERS];
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
const a = order[i], b = order[j];
if (a && b) { order[i] = b; order[j] = a; }
}
const schedule = order.map((character, index) => ({
character,
intervalMs: index === 0 ? PLAZA_WALK.delayMs :
PLAZA_WALK.minIntervalMs + random() * (PLAZA_WALK.maxIntervalMs - PLAZA_WALK.minIntervalMs),
}));
let lastNow: number | undefined;
let elapsed = 0;
let index = 0;
let entrance = PLAZA_WALK.delayMs as number;
return {
schedule,
sample(now: number, paused = false) {
if (lastNow !== undefined && !paused) elapsed += Math.max(0, now - lastNow);
lastNow = now;
while (index < schedule.length) {
const entry = schedule[index];
if (!entry || elapsed < entrance) return null;
const local = elapsed - entrance;
if (local < entry.character.durationMs) return {
character: entry.character,
progress: local / entry.character.durationMs,
frame: Math.floor(local / (entry.character.cycleMs / entry.character.frames)) % entry.character.frames,
};
index++;
const nextInterval = schedule[index]?.intervalMs ?? 0;
entrance += Math.max(entry.character.durationMs, nextInterval);
}
return null;
},
get finished() { return index >= schedule.length; },
};
}
// src/game/rainPolicy.ts
/** Touch devices stay excluded even in landscape or a wide virtual stage. */
export const RAIN_MEDIA_QUERY = "(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";
export function canAnimateRain(width: number, hasHover: boolean, finePointer: boolean, reducedMotion: boolean) {
return width >= 768 && hasHover && finePointer && !reducedMotion;
}