// SKATEMAKER PRO — prop colliders. Furniture you can't ride through: bins, planters, // tree trunks, bollards. Derived (never stored): kit props carry a `collider` hint in // the manifest; builtin props have a table here. Test ride resolves rider-vs-collider // as 2D circle-vs-shape push-out, and exports embed the world-space list as COLLIDERS // so the game can adopt the same rule when it's ready (additive — nothing existing // in the level.js contract changes). // // world entry: { type:'box', x, z, rot, hw, hd, h } | { type:'circle', x, z, r, h } export const BUILTIN_COLLIDERS = { jersey: { type: 'box', hw: 0.31, hd: 1.0, h: 0.94 }, picnic: { type: 'box', hw: 0.8, hd: 0.77, h: 0.78 }, bench: { type: 'box', hw: 0.8, hd: 0.22, h: 0.75 }, trashcan: { type: 'circle', r: 0.36, h: 0.96 }, hydrant: { type: 'circle', r: 0.18, h: 0.78 }, fence: { type: 'box', hw: 1.2, hd: 0.06, h: 1.85 }, floodlight: { type: 'circle', r: 0.13, h: 6.5 }, bleachers: { type: 'box', hw: 1.5, hd: 0.84, h: 1.0 }, fountain: { type: 'circle', r: 0.23, h: 0.9 }, fig: { type: 'circle', r: 0.45, h: 2.6 }, // parkingblock / grate / shadesail posts: low or sparse — ride over/through is fine }; // kit manifest collider -> normalized local shape function kitShape(c) { if (!c || c.type === 'none') return null; if (c.type === 'cylinder') return { type: 'circle', r: c.r, h: c.h }; if (c.type === 'box') return { type: 'box', hw: c.hw, hd: c.hd, h: c.h }; return null; } // all world-space colliders for a park. kitMap: id -> manifest def (or null/empty) export function propColliders(park, kitMap = {}) { const out = []; for (const pr of park.props || []) { let shape = null; if (pr.src.startsWith('builtin:')) shape = BUILTIN_COLLIDERS[pr.src.slice(8)]; else if (pr.src.startsWith('kit:')) shape = kitShape(kitMap[pr.src.slice(4)]?.collider); if (!shape) continue; const sc = pr.scale || 1; const e = { ...shape, x: pr.x, z: pr.z, rot: pr.rot || 0, h: shape.h * sc }; if (e.type === 'circle') e.r *= sc; else { e.hw *= sc; e.hd *= sc; } out.push(e); } return out; } // push a rider circle (px, pz, radius R, foot height y) out of colliders. // returns null (no contact) or the corrected [x, z]. export function resolveCollision(colliders, px, pz, R, y) { let hit = null; for (const c of colliders) { if (y > c.h) continue; // sailing over it if (c.type === 'circle') { const dx = px - c.x, dz = pz - c.z; const d = Math.hypot(dx, dz), min = c.r + R; if (d < min) { const k = d > 1e-4 ? (min / d) : 1, nx = d > 1e-4 ? dx / d : 1; px = c.x + (d > 1e-4 ? dx * k : min); pz = c.z + (d > 1e-4 ? dz * k : 0); hit = true; } } else { const co = Math.cos(c.rot), sn = Math.sin(c.rot); const dx = px - c.x, dz = pz - c.z; const lu = dx * co - dz * sn, lv = dx * sn + dz * co; // three Y-rot inverse const cu = Math.max(-c.hw, Math.min(c.hw, lu)); const cv = Math.max(-c.hd, Math.min(c.hd, lv)); const du = lu - cu, dv = lv - cv; const d = Math.hypot(du, dv); if (d < R) { let nu, nv; if (d > 1e-4) { nu = du / d; nv = dv / d; } else { // inside: push out shortest face const eu = c.hw - Math.abs(lu), ev = c.hd - Math.abs(lv); if (eu < ev) { nu = Math.sign(lu) || 1; nv = 0; } else { nu = 0; nv = Math.sign(lv) || 1; } } const ou = cu + nu * R, ov = cv + nv * R; px = c.x + ou * co + ov * sn; pz = c.z - ou * sn + ov * co; hit = true; } } } return hit ? [px, pz] : null; }