From 4ba242f0f531abd3b45ea92e6972092df7e2bc9d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 15:20:34 -0700 Subject: [PATCH] xbox: expand the 3D skate park and ground the rider's stride [skip ci] --- appveyor.yml | 11 +- xbox/live/mac-test.html | 24 +- xbox/live/oskiewar.js | 403 +++++++++++++++++-------- xbox/live/tests/xbox-feedback.test.mjs | 103 ++++++- 4 files changed, 407 insertions(+), 134 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 6e72457c7a..08caaf0e27 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,9 +2,18 @@ version: 1.0.{build} image: Visual Studio 2022 clone_depth: 1 +# Gameplay and preview assets use the live deploy lane. Queue Windows only +# for native code, its tests, packaged resources, or this build configuration. only_commits: files: - - xbox/ + - xbox/native-bios/ + - xbox/runtime/ + - xbox/assets/ + - xbox/themes/photorealistic/native/ + - xbox/test-native-windows.cmd + - system/backend/kidlisp-ast.mjs + - system/public/type/webfonts/ywft-processing-regular.ttf + - system/public/papers.aesthetic.computer/foundry/fonts/ComicRelief-Regular.ttf - appveyor.yml clone_script: diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index d62a197c25..042ef8a1d0 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -539,6 +539,24 @@ motor.level = 0; } + let skateRoll=null; + function skateAudio(speed,volume){ + if(!Number.isFinite(speed)||!Number.isFinite(volume))return; + if(!skateRoll){ + if(!(volume>0)||!audioReady())return; + const ac=audioContext,buffer=ac.createBuffer(1,ac.sampleRate*2,ac.sampleRate),data=buffer.getChannelData(0); + let seed=0x51a7e,low=0; + for(let i=0;i>>0;low+=(seed/4294967296*2-1-low)*.12;data[i]=low;} + // Crossfade the loop boundary; the source and gain stay allocated. + for(let i=0;i<512;i++){const t=i/512;data[i]=data[data.length-512+i]*(1-t)+data[i]*t;} + const source=ac.createBufferSource(),gain=ac.createGain();source.buffer=buffer;source.loop=true; + gain.gain.value=0;source.connect(gain).connect(ac.destination);source.start();skateRoll={source,gain}; + } + const now=audioContext.currentTime; + skateRoll.source.playbackRate.setTargetAtTime(.55+Math.max(0,Math.min(1,speed))*1.35,now,.04); + skateRoll.gain.gain.setTargetAtTime(Math.max(0,Math.min(.5,volume)),now,.015); + } + // Cached ElevenLabs female:3 rendition of "Oskie War". Separate media // elements create a compact feedback echo without requiring cross-origin // Web Audio access to the public AC audio CDN. @@ -1620,7 +1638,7 @@ globalThis.__fightHost = { runtime, gamepad, controllers, capabilities, telemetry, gameSignal, sampleInput: sampleGamepads, - saveReplay, publishLive, ac, drum, synth, oscillator, oscillatorStop, + saveReplay, publishLive, ac, drum, synth, oscillator, oscillatorStop, skateAudio, wipe, box, line, triangle, write, systemWrite, comicWrite, gameView, flushFaces, canvasContext: context, attachScene, beginPaint, presentScene, triangle3d: undefined, @@ -1875,11 +1893,11 @@ performance.mark("oskiewar:source"); const piece = new Function( "runtime", "gamepad", "controllers", "capabilities", "telemetry", - "gameSignal", "saveReplay", "publishLive", "analytics", "ac", "drum", "synth", "oscillator", "oscillatorStop", "wipe", "box", + "gameSignal", "saveReplay", "publishLive", "analytics", "ac", "drum", "synth", "oscillator", "oscillatorStop", "skateAudio", "wipe", "box", "line", "triangle", "triangle3d", "frame", "write", "systemWrite", "comicWrite", "gameView", "themeReady", "themeAssetReady", "themeSprite", "themeQuad", "themeLighting", `${source}\nreturn { boot, sim, paint };` )(...["runtime", "gamepad", "controllers", "capabilities", "telemetry", - "gameSignal", "saveReplay", "publishLive", "analytics", "ac", "drum", "synth", "oscillator", "oscillatorStop", "wipe", "box", + "gameSignal", "saveReplay", "publishLive", "analytics", "ac", "drum", "synth", "oscillator", "oscillatorStop", "skateAudio", "wipe", "box", "line", "triangle", "triangle3d", "frame", "write", "systemWrite", "comicWrite", "gameView", "themeReady", "themeAssetReady", "themeSprite", "themeQuad", "themeLighting"] .map((name) => globalThis.__fightHost[name])); piece.boot(); diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index f69f9bfd3b..89bf22e912 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -102,7 +102,7 @@ if (hostAnalytics) const hostAc = typeof ac === "function" ? ac : null; // Monotonic count of committed revisions to this piece (next revision included). -const buildVersion = 180; +const buildVersion = 181; const floorY = 1800; // Oskiewar now opens as a versus game. An ordinary web visit hosts a room — // the URL becomes the invitation — and until a friend opens it, all you can @@ -414,7 +414,7 @@ function configureWorldMap(name, course = skateCourse) { const previousMap = skateparkMap + "/" + skateCourse; skateparkMap = name === "skatepark"; skateCourse = course === "pool" ? "pool" : course === "halfpipe" ? "halfpipe" : course === "indoor" ? "indoor" : "park"; - roomDepth = poolOnly() ? 1640 : 900; + roomDepth = poolOnly() ? 6000 : 900; worldNear = -roomDepth / 2; worldFar = roomDepth / 2; // The marks on the ground belong to the ground: kept through resets, // cleared only when the map itself changes. @@ -429,7 +429,7 @@ function configureWorldMap(name, course = skateCourse) { skateBoosts.length = 0; if (skateparkMap && !indoor && !halfpipeOnly()) { skateLoops.push(...parkLoops); skateBoosts.push(...parkBoosts); } if(indoor)skateLoops.push({x:tileCenterX(44),radius:260}); - gridCols = !skateparkMap ? 40 : halfpipeOnly() ? 24 : indoor ? indoorCols : 320; + gridCols = !skateparkMap ? 40 : poolOnly() ? 96 : halfpipeOnly() ? 24 : indoor ? indoorCols : 320; gridWidth = gridCols * tileSize; if (gridField.length !== gridCols * gridRows) gridField = new Float32Array(gridCols * gridRows); worldRight = gridLeft + gridWidth + wallThickness; @@ -1578,24 +1578,58 @@ function terrainSeed(value) { } return (hash >>> 0) / 4294967296 * Math.PI * 2; } -// Rounded rectangular skate pool. Physics, mesh and decals share this surface. -const pool = { halfX: 480, halfZ: 240, radius: 480, deck: 100 }; -function poolDistance(x, z) { - const dx = x - (gridLeft + gridWidth / 2); - const a = Math.max(0, Math.abs(dx) - pool.halfX); - const b = Math.max(0, Math.abs(z) - pool.halfZ); - const d = Math.hypot(a, b); - return { d, nx: d ? Math.sign(dx) * a / d : 0, nz: d ? Math.sign(z) * b / d : 0 }; -} -function poolFloorAt(x, z = 0) { - const d = Math.min(pool.radius, poolDistance(x, z).d); - return floorY - pool.radius + Math.sqrt(Math.max(0, pool.radius ** 2 - d ** 2)); -} -function poolSlopeAt(x, z) { - const { d, nx, nz } = poolDistance(x, z); - if (d >= pool.radius) return { x: 0, z: 0 }; - const slope = -d / Math.sqrt(Math.max(36, pool.radius ** 2 - d ** 2)); - return { x: slope * nx, z: slope * nz }; +// Every feature uses the same surface for wheels, feet, camera and decals. +const pool = { x:1080,z:0,halfX:480,halfZ:240,radius:480,deck:100 }; +const parkPools=[pool, + {x:4700,z:-1500,halfX:520,halfZ:240,radius:520,deck:120}, + {x:6900,z:1600,halfX:360,halfZ:180,radius:420,deck:120}]; +const parkHills=[ + {x:3000,z:1800,rx:750,rz:800,height:320}, + {x:7200,z:-1600,rx:1000,rz:800,height:420}, + {x:1000,z:2000,rx:750,rz:700,height:220}]; +const parkRamps=[ + {x:2800,z:-1450,hx:500,hz:380,tx:220,tz:130,height:230}, + {x:4700,z:700,hx:650,hz:400,tx:270,tz:180,height:300}, + {x:7250,z:0,hx:800,hz:440,tx:400,tz:200,height:340}]; +const parkDeckY=floorY-480; +function bowlDistance(x,z,bowl){ + const dx=x-bowl.x,dz=z-bowl.z,a=Math.max(0,Math.abs(dx)-bowl.halfX),b=Math.max(0,Math.abs(dz)-bowl.halfZ),d=Math.hypot(a,b); + return {d,nx:d?Math.sign(dx)*a/d:0,nz:d?Math.sign(dz)*b/d:0,bowl}; +} +function poolDistance(x,z){ + let best=null; + for(const bowl of parkPools){const edge=bowlDistance(x,z,bowl);if(!best||edge.d-bowl.radiuslimit)position={...position,x:position.x-edge.nx*(edge.d-limit),z:position.z-edge.nz*(edge.d-limit)}; + const subjectEdge=poolDistance(subject.x,subject.z),bowl=subjectEdge.bowl; + const edge=bowlDistance(position.x,position.z,bowl),limit=bowl.radius-20; + const contain=clamp((bowl.radius+150-subjectEdge.d)/150,0,1); + if(contain&&edge.d>limit)position={...position,x:position.x-edge.nx*(edge.d-limit)*contain,z:position.z-edge.nz*(edge.d-limit)*contain}; let y=position.y; for(let i=1;i<20;i++){ const t=i/20,x=lerp(position.x,subject.x,t),z=lerp(position.z,subject.z,t); @@ -9453,7 +9490,7 @@ function updateCameraDoll(dt, now) { const amount=clamp((progress-.08)/.6,0,1),ease=amount*amount*(3-2*amount); goal=vert.heading+Math.atan2(Math.sin(inward-vert.heading),Math.cos(inward-vert.heading))*ease; }else{ - if(!p.grounded&&p.vy>0&&wall.d>pool.radius*.6) + if(!p.grounded&&p.vy>0&&wall.d>wall.bowl.radius*.6&&wall.d180?Math.atan2(p.vz,p.vx):poolCameraReturnYaw; @@ -12178,6 +12215,7 @@ function updatePoolPlayer(p, pad, dt, now) { const axis=value=>Math.abs(value)>.18?Math.sign(value)*(Math.abs(value)-.18)/.82:0; const turn=Number(held.includes('ArrowRight'))-Number(held.includes('ArrowLeft')) || axis(pad.leftX||0); const throttle=Number(held.includes('ArrowUp'))-Number(held.includes('ArrowDown')) || axis(pad.leftY||0); + const wasGrounded=p.grounded; const oldHeading=p.poolYaw||0; const speedBefore=Math.hypot(p.vx,p.vz||0); const turnRate=p.onewheel?lerp(2.8,1.9,clamp(speedBefore/3000,0,1)):3.2; @@ -12244,12 +12282,16 @@ function updatePoolPlayer(p, pad, dt, now) { continue; } slope=poolSlopeAt(p.x,p.z); + const nextVy=slope.x*p.vx+slope.z*p.vz; + if(p.skateboard&&speed>650&&previousVy < -100&&nextVy-previousVy>1800*step*1.5+3){ + p.grounded=false;p.y=Math.min(nextFloor,previousY+previousVy*step);p.vy=previousVy+1800*step;continue; + } const bent=Math.hypot(p.vx,slope.x*p.vx+slope.z*p.vz,p.vz); if(bent>0){p.vx*=speed/bent;p.vz*=speed/bent;} p.vy=slope.x*p.vx+slope.z*p.vz; p.y=poolFloorAt(p.x,p.z); const lip=poolDistance(p.x,p.z); - if(lip.d>=pool.radius-5&&lip.d=lip.bowl.radius-5&&lip.d4&&p.vy < -180){ p.grounded=false;p.skateAirAt=now; // Keep the halfpipe's vert: rise above the coping, then land on the // same curve so the fall becomes rolling speed back into the bowl. @@ -12274,18 +12316,22 @@ function updatePoolPlayer(p, pad, dt, now) { p.grounded=true;p.poolVert=null;p.landPoseUntil=now+110000; } } - const edge=poolDistance(p.x,p.z),limit=pool.radius+pool.deck-25; - if(edge.d>limit){ - p.x-=edge.nx*(edge.d-limit);p.z-=edge.nz*(edge.d-limit); - const outward=p.vx*edge.nx+p.vz*edge.nz; - if(outward>0){p.vx-=outward*edge.nx;p.vz-=outward*edge.nz;} - } + boundParkBody(p); + } + if(p.skateboard&&!p.onewheel&&p.grounded&&!wasGrounded){ + playDrum('clap',clamp((p.landingSpeed||0)/2400,.25,.8),panPlayer(p)); + emitSignal('skate-land',p.pad,p.landingSpeed||0,0); } const horizontal=Math.hypot(p.vx,p.vz||0); + if(p.grounded&&!p.skateboard){ + const forward=p.vx*driveX+(p.vz||0)*driveZ; + p.poolStridePhase=(p.poolStridePhase||0)+forward*dt/240; + } const slope=poolSlopeAt(p.x,p.z||0),heading=p.poolYaw||0; const pitch=p.grounded?Math.atan(slope.x*Math.cos(heading)+slope.z*Math.sin(heading)):0; p.skatePitch+=(pitch-(p.skatePitch||0))*(1-Math.exp(-dt*16)); p.skateVx=p.skateboard?Math.hypot(p.vx,p.vy,p.vz||0):0; + if(p.skateboard&&p.grounded)p.skateSpin=(p.skateSpin||0)+(p.vx*driveX+(p.vz||0)*driveZ)*dt/skateWheelRadius; p.stance=p.attackKind?'ATTACK':!p.grounded?'JUMP':p.ducking?'CROUCH':horizontal>40?'WALK':'NEUTRAL'; p.walkSince=horizontal>40?(p.walkSince||now):0; p.hit=Math.max(0,(p.hit||0)-dt*2);p.previous=held.slice(); @@ -13929,6 +13975,7 @@ function gameSim() { } // The freeskate pause menu holds the world still while it is open. if (freeskateMenu) { + if(typeof skateAudio==='function')skateAudio(0,0);skateAudioLevel=0; updateFreeskateMenu(now); return; } @@ -14159,6 +14206,7 @@ function gameSim() { updateWheelTurbo(dt,now); if (halfpipeOnly()) for(const p of activePlayers())trackMonowheelSurface(p); updateMotorAudio(dt); + updateSkateAudio(dt); updateSeatHeartbeat(dt,now); updateWaterSplashes(dt); updatePowerups(now); @@ -14966,7 +15014,7 @@ function runnerWorldGeometry(player, t) { } if(poolOnly()&&!isHeadOnly(player)){ const z=player.z||0,bank=player.poolLean||0; - if(!player.skateboard){ + if(!player.skateboard&&!pose.spatial){ for(const bone of pose.segments){ const side=bone.part?.startsWith('left-')?-1:bone.part?.startsWith('right-')?1:0; const width=bone.part?.includes('arm')?22:13; @@ -15015,14 +15063,15 @@ function updateRig(player, pad, dt) { lastGrounded: player.grounded }; const board = player.skateboard; const strength = board ? 1 : .35; - const accel = clamp((player.vx - rig.lastVx) / dt, -9000, 9000); - rig.lastVx = player.vx; + const along=poolOnly()?player.vx*Math.cos(player.poolYaw||0)+(player.vz||0)*Math.sin(player.poolYaw||0):player.vx; + const accel = clamp((along - rig.lastVx) / dt, -9000, 9000); + rig.lastVx = along; // Inertia: the body wants to stay where it was, so it leans against the // board's acceleration. Units are the neck's sideways travel. // On the board the stick is a weight shift too: the body leans the way // it is held, and the deck tilts under it (see `updateBoardTilt`). const held = pad?.down || []; - const stick = board ? (held.includes("ArrowRight") ? 1 : held.includes("ArrowLeft") ? -1 : 0) : 0; + const stick = board && !poolOnly() ? (held.includes("ArrowRight") ? 1 : held.includes("ArrowLeft") ? -1 : 0) : 0; springStep(rig, "lean", clamp(-accel * .012 + stick * 26, -46, 46) * strength, 70, 5.5, dt); const pumping = board && (pad?.down || []).includes(depthControls()?"X":"ArrowDown"); if (player.grounded && !rig.lastGrounded) { @@ -15169,8 +15218,54 @@ function hangGeometry(player) { return { head, segments }; } +// A spatial gait: feet travel backward through stance, lift on recovery, +// and arms counter the legs. The distance phase works at every heading. +function buildPoolWalkGeometry(p) { + const phase=p.poolStridePhase||0,speed=clamp(Math.hypot(p.vx,p.vz||0)/700,0,1); + const moving=clamp(Math.hypot(p.vx,p.vz||0)/200,0,1),crouch=clamp(p.crouchBlend||0,0,1); + const grounded=p.grounded,air=grounded?0:1,cy=Math.cos(p.poolYaw||0),sy=Math.sin(p.poolYaw||0); + const bob=grounded?Math.cos(phase*Math.PI*4)*3*speed:0; + const hipHeight=86-11*speed-30*crouch+bob; + const lean=(p.rig?.lean||0)*.18+(p.inputZ||0)*speed*9+crouch*13; + const point=(x,y,z)=>({x:p.x+x,y:p.y+y,z:(p.z||0)+z}); + const hip=point(-crouch*9,-hipHeight,0),shoulder=point(lean,-hipHeight-52+12*crouch,0); + const head=point(lean+2,-hipHeight-80+12*crouch,0),segments=[];head.radius=22; + const bone=(a,b,role,part,width=10)=>{if(hasPart(p,part))segments.push({x1:a.x,y1:a.y,z1:a.z,x2:b.x,y2:b.y,z2:b.z,width,role,part});}; + // Solve a knee/elbow in 3D in the plane containing the forward axis. + const joint=(a,b,length,bend=1)=>{ + const dx=b.x-a.x,dy=b.y-a.y,dz=b.z-a.z,d=Math.hypot(dx,dy,dz)||1; + const nx=dx/d,ny=dy/d,nz=dz/d,px=1-nx*nx,py=-nx*ny,pz=-nx*nz,pl=Math.hypot(px,py,pz)||1; + const h=Math.sqrt(Math.max(0,length*length-d*d/4))*bend; + return {x:(a.x+b.x)/2+px/pl*h,y:(a.y+b.y)/2+py/pl*h,z:(a.z+b.z)/2+pz/pl*h}; + }; + bone(point(head.x-p.x,head.y-p.y+17,0),shoulder,'neck','torso'); + bone(shoulder,hip,'torso','torso'); + const twist=Math.sin(phase*Math.PI*2)*moving*4; + const shoulders=[point(lean-twist,shoulder.y-p.y,-23),point(lean+twist,shoulder.y-p.y,23)]; + bone(shoulders[0],shoulders[1],'shoulders','torso'); + for(const [index,name] of ['left','right'].entries()){ + const side=index?1:-1,u=((phase+index*.5)%1+1)%1,stance=u<.55; + const recovery=clamp((u-.55)/.45,0,1); + const footX=(stance?66-u/.55*132:-66*Math.cos(recovery*Math.PI))*moving*(1-crouch*.55); + const lift=grounded?(stance?0:Math.sin(recovery*Math.PI)*(20+16*speed)*moving):24+index*8; + const localX=grounded?footX:18-index*32,localZ=side*14; + const surface=grounded?clamp(poolFloorAt(p.x+localX*cy-localZ*sy,(p.z||0)+localX*sy+localZ*cy)-p.y,-36,36):0; + const foot=point(localX,surface-5-lift,localZ),pelvis={...hip,z:hip.z+side*12}; + const knee=joint(pelvis,foot,48); + bone(pelvis,knee,name+'-thigh',name+'-leg',11);bone(knee,foot,name+'-shin',name+'-leg',10); + const upper=shoulders[index],swing=-footX*.52; + const hand=point(lean+swing+air*20,shoulder.y-p.y+61-10*speed-15*air,side*(26+air*12)); + const elbow=joint(upper,hand,33,-1); + bone(upper,elbow,name+'-upper-arm',name+'-arm');bone(elbow,hand,name+'-forearm',name+'-arm'); + } + return {head,segments,spatial:true}; +} + function buildRunnerWorldGeometry(player, t, at = null) { if (player.spiderDummy) return spiderDummyWorldGeometry(player, t); + if(poolOnly()&&freeskateActive()&&!player.skateboard&&!player.attackKind&&!player.dummy&& + !isHeadOnly(player)&&!isSwimmer(player)&&hasPart(player,'left-leg')&&hasPart(player,'right-leg')) + return buildPoolWalkGeometry(player); if (player.skateStall && player.skateboard && !isHeadOnly(player)) return hangGeometry(player); // Rendering consumes the same fixed 60 Hz phase clock as simulation. The // display can remain uncapped without inventing in-between combat poses. @@ -18988,37 +19083,60 @@ function terrainSurfaceShades(color) { } let poolSurfaceMesh=null; function drawPoolGeometry() { - const center=gridLeft+gridWidth/2,segments=12,ink=[172,212,211]; - const ring=(radius,y)=>{ - const points=[]; - for(let corner=0;corner<4;corner++)for(let i=0;i<=segments;i++){ - const a=(corner+i/segments)*Math.PI/2; - const sx=corner===0||corner===3?1:-1,sz=corner<2?1:-1; - points.push({x:center+sx*pool.halfX+Math.cos(a)*radius,y,z:sz*pool.halfZ+Math.sin(a)*radius}); - } - return points; + const ink=[172,212,211],ground=[190,201,196],rampInk=[199,185,171]; + const holes=parkPools.map(b=>({x:b.x,z:b.z,hx:b.halfX+b.radius+b.deck,hz:b.halfZ+b.radius+b.deck})).concat(parkRamps); + // Include hole edges in the flat mesh so no triangle bridges a bowl. + const cuts=(low,high,axis,half)=>{ + const values=[low,high];for(let n=low+160;nn>=low&&n<=high))].sort((a,b)=>a-b); }; - // Large bottom tiles keep the floor legible while the walls turn in both axes. - for(let x=-pool.halfX;xbands?3:0); - const current=ring(r,y); - for(let i=0;i({x,y:parkGroundAt(x,z),z}); + for(let i=1;iMath.abs(x-h.x){ + const raw=[]; + for(let corner=0;corner<4;corner++)for(let i=0;i<=12;i++){ + const a=(corner+i/12)*Math.PI/2,sx=corner===0||corner===3?1:-1,sz=corner<2?1:-1; + raw.push({x:bowl.x+sx*bowl.halfX+Math.cos(a)*radius,z:bowl.z+sz*bowl.halfZ+Math.sin(a)*radius}); + } + const result=[]; + for(let i=0;i{const dx=p.x-bowl.x,dz=p.z-bowl.z,k=Math.min(hx/Math.abs(dx||.0001),hz/Math.abs(dz||.0001));return {x:bowl.x+dx*k,y:parkDeckY,z:bowl.z+dz*k};}); + for(let i=0;i[[-hx,-hz],[hx,-hz],[hx,hz],[-hx,hz]].map(([x,z])=>({x:r.x+x,y,z:r.z+z})); + const base=corners(r.hx,r.hz,parkDeckY),top=corners(r.tx,r.tz,parkDeckY-r.height); + worldQuad(...top,rampInk); + for(let i=0;i<4;i++){const j=(i+1)%4;worldQuad(base[i],base[j],top[j],top[i],rampInk);} } } let pipeSurfaceMesh=null,pipeSurfaceKey=''; @@ -19551,62 +19669,88 @@ function projectedBallRadius(ball) { // a dark underside, the aluminium body, a bright top edge — so it reads as a // solid casting. The deck end rides the suspension (`skateFrame` sinks it), // the axle end stays with the wheels, so the bushings visibly squash. -function drawTruck(at, along, scale) { - const inward = along > 0 ? -1 : 1; - const capsule = (a, b, width, color) => { - const p = at(...a), q = at(...b); - worldCapsule(p.x, p.y, p.z, q.x, q.y, q.z, Math.max(.4, width * scale), color); - }; - const steel = [104, 108, 120], body = [182, 188, 200], shine = [236, 240, 248], - shadow = [112, 118, 132], bushing = [236, 128, 46]; - if(scale<.65){ - capsule([along-8,1,0],[along+8,1,0],3,steel); - capsule([along,1,0],[along,skateAxleDrop,0],6,body); - capsule([along,skateAxleDrop,-16],[along,skateAxleDrop,16],3,steel);return; - } - // Baseplate under the deck (this end sinks with the deck). - capsule([along - 8, .6, 0], [along + 8, .6, 0], 3, steel); - // Kingpin with its bushings, squashed between plate and hanger. - capsule([along + inward * 3, 1, 0], [along + inward * 3.5, skateAxleDrop - 4, 0], 2, steel); - capsule([along + inward * 3, 1.4, -3], [along + inward * 3, 1.4, 3], 4.5, bushing); - // The hanger: pivot to axle, shaded underside / body / top edge. - capsule([along + inward * 5, 2, 0], [along, skateAxleDrop, 0], 7.5, shadow); - capsule([along + inward * 5, 1.6, 0], [along, skateAxleDrop - .6, 0], 5.5, body); - capsule([along + inward * 5.4, 1.2, 0], [along - inward * .6, skateAxleDrop - 2.4, 0], 1.6, shine); - // Across, between the wheels: the hanger's arms and the axle ends. - capsule([along, skateAxleDrop - .5, -11], [along, skateAxleDrop - .5, 11], 5.5, body); - capsule([along, skateAxleDrop - 2.6, -11], [along, skateAxleDrop - 2.6, 11], 1.4, shine); - capsule([along, skateAxleDrop, -16], [along, skateAxleDrop, 16], 2, steel); -} - -function drawBoardRunningGear(player, at, nearSide, urethane) { - const scale = cameraScale(); - for (const along of [-skateTruck, skateTruck]) drawTruck(at, along, scale); - const spin = player.skateSpin || 0; - const wheel = (along, side) => { - const center = at(along, skateAxleDrop, side * 15); - const point = projectPoint(center.x, center.y, center.z); - if (![point.x, point.y].every(Number.isFinite)) return; - const radius = Math.max(1.5, skateWheelRadius * scale); - const shade = side === nearSide ? 0 : .35; - const previous = triangleDepth; - // The near wheels sit in front of the trucks, the far ones behind them. - triangleDepth = point.z + (side === nearSide ? -.006 : .006); - filledDisc(point.x, point.y, radius, mixColor(mixColor(urethane, [90, 36, 10], .45), [0, 0, 0], shade)); - filledDisc(point.x, point.y, radius * .86, mixColor(urethane, [0, 0, 0], shade)); - filledDisc(point.x, point.y, radius * .42, [44, 42, 46]); - filledDisc(point.x, point.y, radius * .3, mixColor([206, 210, 220], [0, 0, 0], shade)); - const mark = (turn, inner, outer, color) => screenTriangle( - point.x + Math.cos(spin + turn) * radius * inner, point.y + Math.sin(spin + turn) * radius * inner, - point.x + Math.cos(spin + turn) * radius * outer, point.y + Math.sin(spin + turn) * radius * outer, - point.x + Math.cos(spin + turn + .45) * radius * outer, point.y + Math.sin(spin + turn + .45) * radius * outer, - ...color); - mark(0, .45, .82, mixColor(urethane, [255, 255, 255], .35)); - mark(Math.PI, .45, .82, mixColor(urethane, [80, 30, 8], .3)); - triangleDepth = previous; - }; - for (const along of [-skateTruck, skateTruck]) wheel(along, -nearSide); - for (const along of [-skateTruck, skateTruck]) wheel(along, nearSide); +// Solid local-space cylinders carry real per-vertex depth. Screen capsules +// and discs assigned the whole truck/wheel one biased depth, making hardware +// show through the deck whenever the camera turned above the board. +function boardCylinder(at,a,b,radius,color,sides=8){ + const d=b.map((v,i)=>v-a[i]),length=Math.hypot(...d)||1,n=d.map(v=>v/length); + const u=Math.abs(n[2])<.9?[n[1],-n[0],0]:[-n[2],0,n[0]]; + const ul=Math.hypot(...u)||1;for(let i=0;i<3;i++)u[i]/=ul; + const v=[n[1]*u[2]-n[2]*u[1],n[2]*u[0]-n[0]*u[2],n[0]*u[1]-n[1]*u[0]]; + const ring=(p,t)=>at(...p.map((value,i)=>value+radius*(u[i]*Math.cos(t)+v[i]*Math.sin(t)))); + const ca=at(...a),cb=at(...b); + let p=ring(a,0),q=ring(b,0); + for(let i=1;i<=sides;i++){ + const angle=i*Math.PI*2/sides,r=ring(a,angle),t=ring(b,angle); + worldQuad(p,q,t,r,color);worldTriangle(ca,r,p,color);worldTriangle(cb,q,t,color); + p=r;q=t; + } +} +function drawTruck(at,along){ + const inward=along>0?-1:1,steel=[104,108,120],body=[182,188,200],bushing=[236,128,46]; + const rod=(a,b,width,color)=>boardCylinder(at,a,b,width/2,color,6); + // A short solid plate below the deck, then the kingpin and cast hanger. + rod([along-8,1.2,0],[along+8,1.2,0],2,steel); + rod([along+inward*3,2,0],[along+inward*4,8,0],2,steel); + rod([along+inward*3,2.5,0],[along+inward*3,5,0],6,bushing); + rod([along+inward*5,4,0],[along,8,0],5,body); + rod([along,8.8,-11],[along,8.8,11],4,body); + rod([along,skateAxleDrop,-17],[along,skateAxleDrop,17],2,steel); +} +function drawBoardRunningGearGeometry(player,at,nearSide,urethane,marks=true){ + for(const along of [-skateTruck,skateTruck])drawTruck(at,along); + const spin=player.skateSpin||0,rubber=mixColor(urethane,[90,36,10],.45); + for(const along of [-skateTruck,skateTruck])for(const side of [-1,1]){ + const z=side*15; + boardCylinder(at,[along,skateAxleDrop,z-3],[along,skateAxleDrop,z+3],skateWheelRadius,rubber,12); + // The two wheel faces and bearings live on the axle's physical planes. + for(const cap of [-1,1]){ + const faceZ=z+cap*3.03,center=at(along,skateAxleDrop,faceZ); + const point=(angle,r)=>at(along+Math.cos(angle)*r,skateAxleDrop+Math.sin(angle)*r,faceZ); + for(let i=0;i<12;i++){ + const a=spin+i*Math.PI/6,b=a+Math.PI/6; + worldTriangle(center,point(a,skateWheelRadius*.86),point(b,skateWheelRadius*.86),urethane); + const bearingZ=faceZ+cap*.03; + worldTriangle(at(along,skateAxleDrop,bearingZ), + at(along+Math.cos(a)*2.6,skateAxleDrop+Math.sin(a)*2.6,bearingZ), + at(along+Math.cos(b)*2.6,skateAxleDrop+Math.sin(b)*2.6,bearingZ),[206,210,220]); + } + if(marks)worldTriangle(point(spin,3.6),point(spin,6.5),point(spin+.45,6.5),mixColor(urethane,[255,255,255],.35)); + } + } +} + +const skateGearMeshes=new Map(),skateGearCamera=new Float32Array(27); +function drawBoardRunningGear(player,at,nearSide,urethane){ + if(nativeRetainedMeshes){ + const key=urethane.join(',');let mesh=skateGearMeshes.get(key); + if(!mesh){ + const original=worldTriangle; + worldTriangle=(a,b,c,color)=>worldQuad(a,b,c,c,color); + try{mesh=captureQuadMesh(()=>drawBoardRunningGearGeometry({},(x,y,z)=>({x,y,z}),nearSide,urethane,false));} + finally{worldTriangle=original;} + skateGearMeshes.set(key,mesh); + } + const handle=meshHandle(mesh); + if(handle>=0){ + const pivot=at(0,2,0),axes=[at(1,2,0),at(0,3,0),at(0,2,1)].map(p=>[p.x-pivot.x,p.y-pivot.y,p.z-pivot.z]); + const origin=[pivot.x-2*axes[1][0],pivot.y-2*axes[1][1],pivot.z-2*axes[1][2]],camera=mainNativeCamera(),local=skateGearCamera; + local.set(camera);const dot=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; + const position=[camera[0]-origin[0],camera[1]-origin[1],camera[2]-origin[2]]; + for(let j=0;j<3;j++){ + local[j]=dot(position,axes[j]); + for(const start of [3,6,9,24])local[start+j]=dot([camera[start],camera[start+1],camera[start+2]],axes[j]); + } + nativeMeshDraw(handle,local,0); + const spin=player.skateSpin||0; + for(const along of [-skateTruck,skateTruck])for(const side of [-1,1])for(const cap of [-1,1]){ + const z=side*15+cap*3.08,point=(a,r)=>at(along+Math.cos(a)*r,skateAxleDrop+Math.sin(a)*r,z); + worldTriangle(point(spin,3.6),point(spin,6.5),point(spin+.45,6.5),mixColor(urethane,[255,255,255],.35)); + } + return; + } + } + drawBoardRunningGearGeometry(player,at,nearSide,urethane); } function drawSkateboard(player) { @@ -21790,6 +21934,7 @@ function paint() { } function act() {} function leave() { + if(typeof skateAudio==='function')skateAudio(0,0); if(motorRunning && typeof oscillatorStop==='function')oscillatorStop(); try { roundViewerStop?.(); @@ -21813,8 +21958,7 @@ function updatePoolLooseVehicle(v,dt,now,board){ } const oldY=v.y; v.x+=v.vx*step;v.z=(v.z||0)+(v.vz||0)*step; - const edge=poolDistance(v.x,v.z),limit=pool.radius+pool.deck-25; - if(edge.d>limit){v.x-=edge.nx*(edge.d-limit);v.z-=edge.nz*(edge.d-limit);v.vx=v.vz=0;} + boundParkBody(v); const nextFloor=poolFloorAt(v.x,v.z)-offset; v.vy+=1800*step;v.y+=v.vy*step; if(v.y>=nextFloor||(supported&&nextFloor-oldY<12)){v.y=nextFloor;v.vy=0;} @@ -22026,6 +22170,19 @@ function spawnWaterSplash(p,y){ function updateWaterSplashes(dt){for(let i=waterSplashes.length-1;i>=0;i--){const p=waterSplashes[i];p.life-=dt;if(p.life<=0){waterSplashes.splice(i,1);continue;}p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=850*dt;}} function drawWaterSplashes(){drawTurboParticles();for(const p of waterSplashes){const a=projectPoint(p.x,p.y,p.z);const old=triangleDepth;triangleDepth=a.z;filledDisc(a.x,a.y,Math.max(1,4*cameraScale()*p.life/.7),[187,236,252]);triangleDepth=old;}} +let skateAudioLevel=0; +function updateSkateAudio(dt){ + let speed=0; + for(const p of activePlayers())if(p.alive&&p.skateboard&&!p.onewheel&&p.grounded) + speed=Math.max(speed,Math.hypot(p.vx,p.vy,p.vz||0)); + const rider=activePlayers()[0]; + for(const b of balls)if(b.active&&b.type==='skateboard'&&Math.abs(b.y+18-terrainFloorAt(b.x,b.z||0))<5) + speed=Math.max(speed,Math.hypot(b.vx,b.vz||0)*clamp(1-Math.hypot(b.x-rider.x,(b.z||0)-(rider.z||0))/1800,0,1)); + const amount=clamp(speed/3200,0,1),target=speed>30?.025+Math.sqrt(amount)*.28:0; + skateAudioLevel+=(target-skateAudioLevel)*(1-Math.exp(-dt*(target?12:45))); + if(skateAudioLevel<.0005)skateAudioLevel=0; + if(!netSilent&&typeof skateAudio==='function')skateAudio(amount,skateAudioLevel); +} let motorFrequency=220,motorRunning=false; function updateMotorAudio(dt){ if(typeof oscillator!=='function')return; diff --git a/xbox/live/tests/xbox-feedback.test.mjs b/xbox/live/tests/xbox-feedback.test.mjs index f72d07cc6a..6a0bf5ee4f 100644 --- a/xbox/live/tests/xbox-feedback.test.mjs +++ b/xbox/live/tests/xbox-feedback.test.mjs @@ -7,7 +7,7 @@ const source = await readFile(new URL('../oskiewar.js', import.meta.url), 'utf8' function game(course = "pool", retainedDecals = false) { let now = 0; const post = [], texts = [], faces = [], sounds = [], decalsDrawn = []; - const surface={clears:0,stamps:0,draws:0}; + const surface={clears:0,stamps:0,draws:0},wheelAudio=[]; const pads = [0, 1].map(() => ({ connected: true, down: [], leftX: 0, leftY: 0 })); const context = vm.createContext({ __oskiewarFreeskateMap: course, @@ -21,6 +21,7 @@ function game(course = "pool", retainedDecals = false) { comicWrite: (...args) => texts.push(args), systemWrite: (...args) => texts.push(args), drum: (...args) => sounds.push(args), + skateAudio: (...args) => wheelAudio.push(args), themeAssetReady: asset => asset === 5, themeQuad: (...args) => { decalsDrawn.push(args); return true; }, ...(retainedDecals?{meshUpload:()=>0,meshDraw:()=>0, @@ -38,7 +39,8 @@ function game(course = "pool", retainedDecals = false) { addDecal,decals,rasterDecalPatches,terrainFloorAt,drawDecals, surfaceState:()=>({native:poolDecalsNative,count:poolDecalCount}), poolMesh:()=>captureQuadMesh(drawPoolGeometry), - pool,poolDistance,poolSlopeAt,monowheelFrame,runnerWorldGeometry,updateCameraDoll, + pool,parkPools,parkHills,parkRamps,parkDeckY,poolFloorAt,updateSkateAudio,buildPoolWalkGeometry,poolDistance,poolSlopeAt,monowheelFrame,runnerWorldGeometry,updateCameraDoll, + drawSkateboard,skateFrame,spunSkateFrame, debug:()=>debugHitboxes=true, bounds:()=>({near:worldNear+60,far:worldFar-60}), classic:()=>globalThis.__oskiewarDepthControls=false, @@ -55,7 +57,7 @@ function game(course = "pool", retainedDecals = false) { api.boot(); api.begin(); assert.equal(api.error(), ''); - return { api, context, post, texts, faces, sounds, pads, decalsDrawn, surface, + return { api, context, post, texts, faces, sounds, pads, decalsDrawn, surface, wheelAudio, tick: () => { now += 16667; api.sim(); assert.equal(api.error(), ''); } }; } @@ -63,8 +65,8 @@ test('pool keeps one rider, one monowheel and one skateboard through joins and r const g = game(), a = g.api; const check = () => { assert.equal(a.map().course, 'pool'); - assert.equal(a.map().cols, 24); - assert.equal(a.map().width, 2160); + assert.equal(a.map().cols, 96); + assert.equal(a.map().width, 8640); assert.equal(a.map().features.length, 5); assert.equal(a.activePlayers().length, 1); assert.equal(a.players[1].alive, false); @@ -303,7 +305,8 @@ test('retained pool marks stamp once and keep constant draw work after ten thous test('the bowl uses one solid material and the grounded camera follows lower', () => { const g=game(),a=g.api; - const mesh=a.poolMesh();assert.equal(new Set(mesh.faces.map(f=>f.color.join(','))).size,1); + const mesh=a.poolMesh(),bowlFaces=mesh.faces.filter(f=>f.ids.some(i=>mesh.vertices[i].y>a.parkDeckY+.1)); + assert.ok(bowlFaces.length>3000);assert.deepEqual([...new Set(bowlFaces.map(f=>f.color.join(',')))],['172,212,211']); for(let i=0;i<120;i++)g.tick(); assert.ok(a.cameraDoll.position.y>1100,'lens is lower than the old overhead view'); assert.ok(a.cameraDoll.position.y { + const g=game(),a=g.api; + const board={x:1080,y:1750,z:0,poolYaw:0,skatePitch:0,boardObject:{condition:{health:100,cracks:[]}}}; + a.cameraDoll.snap({target:{x:1080,y:1750,z:0},position:{x:900,y:1100,z:-500}, + width:400,perspective:1,fov:35,roll:0}); + for(let step=0;step<8;step++){ + board.poolYaw=step*Math.PI/4;g.faces.length=0;a.drawSkateboard(board); + const at=a.spunSkateFrame(a.skateFrame(board),board); + for(const truck of [-32,32]){ + const q=at(truck,-4,0),p=a.projectPoint(q.x,q.y,q.z);let nearest=null,depth=Infinity; + for(const t of g.faces){ + const den=(t[4]-t[7])*(t[0]-t[6])+(t[6]-t[3])*(t[1]-t[7]); + if(Math.abs(den)<1e-8)continue; + const u=((t[4]-t[7])*(p.x-t[6])+(t[6]-t[3])*(p.y-t[7]))/den; + const v=((t[7]-t[1])*(p.x-t[6])+(t[0]-t[6])*(p.y-t[7]))/den,w=1-u-v; + if(Math.min(u,v,w)<-1e-6)continue; + const z=u*t[2]+v*t[5]+w*t[8];if(znearest[9], + `grip covers truck ${truck} at heading ${step}, got ${nearest.slice(9)}`); + } + } +}); + test('a glass roof break is one composite cue and still works on the full hall', () => { const {api:a,sounds} = game(); a.configureWorldMap('skatepark','indoor'); a.resetRoofPanes(); @@ -374,7 +402,7 @@ test('pool movement stays on the surface in all four directions and corners', () g.tick(); assert.ok([p.x,p.y,p.z,p.vx,p.vy,p.vz].every(Number.isFinite)); assert.ok(p.y<=g.api.terrainFloorAt(p.x,p.z)+.01,'never enters solid pool'); - assert.ok(g.api.poolDistance(p.x,p.z).d<=556,'contained by pool deck'); + assert.ok(p.x>=25&&p.x<=8615&&Math.abs(p.z)<=2975,'contained by the expanded park'); } } }); @@ -537,3 +565,64 @@ test('camera swings into a vert return, dips toward the landing, and clears the assert.ok(turned,`camera faces return direction ${heading}`);assert.ok(dipped,'camera dips');assert.ok(maxYawStep<.1,`camera snap ${maxYawStep}`); } }); + + +test('spatial gait plants alternating feet and swings the opposite arm at every heading',()=>{ + const g=game(),a=g.api,p=a.players[0]; + Object.assign(p,{x:1080,y:1800,z:0,skateboard:false,onewheel:false,grounded:true,poolLean:0,breathPhase:0}); + for(const heading of [0,Math.PI/2,Math.PI,-Math.PI/2])for(const phase of [.1,.35,.6,.85]){ + Object.assign(p,{vx:600*Math.cos(heading),vz:600*Math.sin(heading),poolYaw:heading,poolStridePhase:phase}); + const pose=a.runnerWorldGeometry(p,0),shins=pose.segments.filter(b=>b.role.endsWith('shin')); + assert.ok(shins.some(b=>Math.abs(b.y2-1795)<.01),'one foot stays planted'); + assert.ok(shins.some(b=>b.y2<1790),'the recovering foot clears the ground'); + for(const name of ['left','right']){ + const foot=shins.find(b=>b.part===name+'-leg'),hand=pose.segments.find(b=>b.role===name+'-forearm'); + const forward=b=>(b.x2-p.x)*Math.cos(heading)+(b.z2-p.z)*Math.sin(heading); + assert.ok(forward(foot)*forward(hand)<=0,'arm counters its leg'); + } + assert.ok(pose.segments.every(b=>[b.x1,b.y1,b.z1,b.x2,b.y2,b.z2].every(Number.isFinite))); + } + Object.assign(p,{vx:0,vz:0,poolYaw:0}); + const idle=a.runnerWorldGeometry(p,0); + assert.ok(idle.segments.filter(b=>b.role.endsWith('shin')).every(b=>Math.abs(b.y2-1795)<.01),'both feet settle at rest'); +}); + +test('skateboard rumble responds to contact and speed without sounding for the monowheel',()=>{ + const g=game(),a=g.api,p=a.players[0];for(const b of a.balls)b.active=false; + Object.assign(p,{skateboard:true,onewheel:false,grounded:true,vx:500,vy:0,vz:0}); + for(let i=0;i<30;i++)a.updateSkateAudio(1/60); + const slow=g.wheelAudio.at(-1);p.vx=2500; + for(let i=0;i<30;i++)a.updateSkateAudio(1/60); + const fast=g.wheelAudio.at(-1);assert.ok(fast[0]>slow[0]&&fast[1]>slow[1]); + p.grounded=false;for(let i=0;i<15;i++)a.updateSkateAudio(1/60); + assert.equal(g.wheelAudio.at(-1)[1],0,'airborne wheels are silent'); + p.grounded=true;p.onewheel=true;for(let i=0;i<10;i++)a.updateSkateAudio(1/60); + assert.equal(g.wheelAudio.at(-1)[1],0,'motor remains a separate sound'); +}); + +test('expanded park has three solid pools, hills and rideable flat-topped trapezoids',()=>{ + const a=game().api; + assert.equal(a.parkPools.length,3);assert.equal(a.parkHills.length,3);assert.equal(a.parkRamps.length,3); + for(const b of a.parkPools){assert.equal(a.poolFloorAt(b.x,b.z),a.parkDeckY+b.radius);assert.equal(a.poolFloorAt(b.x+b.halfX+b.radius,b.z),a.parkDeckY);} + for(const h of a.parkHills){assert.equal(a.poolFloorAt(h.x,h.z),a.parkDeckY-h.height);assert.equal(a.poolFloorAt(h.x+h.rx,h.z),a.parkDeckY);} + for(const r of a.parkRamps){ + for(const dx of [-r.tx,0,r.tx])assert.equal(a.poolFloorAt(r.x+dx,r.z),a.parkDeckY-r.height); + assert.equal(a.poolFloorAt(r.x+r.hx,r.z),a.parkDeckY); + assert.ok(a.poolSlopeAt(r.x+(r.tx+r.hx)/2,r.z).x>0); + } + const mesh=a.poolMesh(); + for(const p of mesh.vertices)assert.ok(Math.abs(p.y-a.poolFloorAt(p.x,p.z))<.05,`mesh collision mismatch at ${p.x},${p.z}`); +}); + +test('both vehicles can ride out through every pool bank and travel across the park',()=>{ + for(const one of [true,false])for(let index=0;index<3;index++){ + const g=game(),a=g.api,p=a.players[0],b=a.parkPools[index]; + Object.assign(p,{x:b.x,z:b.z,y:a.poolFloorAt(b.x,b.z),vx:0,vz:-2400,vy:0,poolYaw:-Math.PI/2,skateboard:true,onewheel:one,grounded:true}); + a.monowheel.active=false;g.pads[0].down=['ArrowUp'];let outside=false; + for(let i=0;i<180;i++){ + g.tick();assert.ok(p.y<=a.poolFloorAt(p.x,p.z)+.01,'vehicle stays above the surface'); + if(p.z