diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 05dc67616a..18bc433424 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 = 182; +const buildVersion = 183; 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 @@ -1579,10 +1579,10 @@ function terrainSeed(value) { return (hash >>> 0) / 4294967296 * Math.PI * 2; } // 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 pool = { x:1080,z:0,halfX:480,halfZ:240,radius:480,depth:220,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}]; + {x:4700,z:-1500,halfX:520,halfZ:240,radius:520,depth:240,deck:120}, + {x:6900,z:1600,halfX:360,halfZ:180,radius:420,depth:190,deck:120}]; const parkHills=[ {x:3000,z:1800,rx:750,rz:800,height:320}, {x:7200,z:-1600,rx:1000,rz:800,height:420}, @@ -1591,7 +1591,13 @@ 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; +const parkDeckY=floorY-pool.depth; +const parkPalette={pool:[172,212,211],ground:[220,185,151], + hills:[[167,188,207],[185,172,209],[171,200,170]],ramp:[218,155,138], + wall:[210,202,190],base:[143,165,167],frame:[68,91,106],ceiling:[188,191,187], + glass:[174,218,233],light:[255,244,196]}; +const parkBuildingHeight=1900; +const parkLights=[1200,3300,5400,7500].flatMap(x=>[-1650,1650].map(z=>({x,z,y:parkDeckY-1300}))); 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}; @@ -1603,10 +1609,8 @@ function poolDistance(x,z){ } function bowlHeight(x,z,bowl){ const d=Math.min(bowl.radius,bowlDistance(x,z,bowl).d),t=d/bowl.radius; - const curved=parkDeckY+Math.sqrt(Math.max(0,bowl.radius*bowl.radius-d*d)); - // A bank on the north wall opens each pool into the rest of the park. - const lane=zlimit)position={...position,x:position.x-edge.nx*(edge.d-limit)*contain,z:position.z-edge.nz*(edge.d-limit)*contain}; + // Keep the camera inside the building; shallow dips no longer need to + // pull the boom into each bowl. Terrain still clears the entire sightline. + position={...position,x:clamp(position.x,gridLeft+80,gridLeft+gridWidth-80), + z:clamp(position.z,worldNear+80,worldFar-80)}; 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); const surface=poolFloorAt(x,z); y=Math.min(y,(surface-subject.y*t)/(1-t)-65); } - return {...position,y}; + return {...position,y:Math.max(parkDeckY-parkBuildingHeight+80,y)}; } function updateCameraDoll(dt, now) { if (poolOnly() && freeskateActive()) { @@ -9512,9 +9516,13 @@ function updateCameraDoll(dt, now) { const target={x:lerp(p.x,lookX,.15+poolCameraDip*.4), y:lerp(p.y-85+clamp(p.vy*.1,-160,220),poolFloorAt(lookX,lookZ)-40,groundFocus), z:lerp(p.z||0,lookZ,.15+poolCameraDip*.4)}; + // Lead the tracking spring during fast bank airs so it does not leave + // the rider above the frame while still looking at the takeoff point. + target.x+=p.vx*.1;target.z+=(p.vz||0)*.1;target.y+=clamp(p.vy*.1,-220,220); + target.y=clamp(target.y,p.y-240,p.y+50); const subject={x:p.x,y:p.y-2,z:p.z||0}; const position=clearPoolCamera({x:target.x-Math.cos(angle)*distance, - y:p.y-85-(lerp(390,880,clamp(airHeight/500,0,1))+poolCameraDip*230+Math.min(500,airHeight*.2))*zoom-playerCameraPitch*600, + y:p.y-85-(lerp(260,880,clamp(airHeight/500,0,1))+poolCameraDip*230+Math.min(500,airHeight*.2))*zoom-playerCameraPitch*600, z:target.z-Math.sin(angle)*distance},subject); cameraCenter=target.x;cameraCenterY=target.y;cameraWidth=1650*zoom; cameraDoll.track({target,position,width:cameraWidth,perspective:1,fov:58+poolCameraDip*5,roll:0},dt,8); @@ -19082,8 +19090,13 @@ function terrainSurfaceShades(color) { return terrainShades; } let poolSurfaceMesh=null; +function parkSurfaceColor(base,x,z){ + let light=0; + for(const lamp of parkLights)light=Math.max(light,Math.max(0,1-Math.hypot(x-lamp.x,z-lamp.z)/1450)); + return mixColor(base,[255,236,197],light*light*.18); +} function drawPoolGeometry() { - const ink=[172,212,211],ground=[190,201,196],rampInk=[199,185,171]; + const ink=parkPalette.pool,ground=parkPalette.ground,rampInk=parkPalette.ramp; 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)=>{ @@ -19095,13 +19108,14 @@ function drawPoolGeometry() { const groundPoint=(x,z)=>({x,y:parkGroundAt(x,z),z}); for(let j=1;j{if(flatFrom===null)return;worldQuad(groundPoint(flatFrom,zs[j-1]),groundPoint(right,zs[j-1]),groundPoint(right,zs[j]),groundPoint(flatFrom,zs[j]),ground);flatFrom=null;}; + const flush=right=>{if(flatFrom===null)return;worldQuad(groundPoint(flatFrom,zs[j-1]),groundPoint(right,zs[j-1]),groundPoint(right,zs[j]),groundPoint(flatFrom,zs[j]),parkSurfaceColor(ground,(flatFrom+right)/2,(zs[j-1]+zs[j])/2));flatFrom=null;}; for(let i=1;iMath.abs(x-h.x)p.y===parkDeckY)){flatFrom??=xs[i-1];continue;} - flush(xs[i-1]);worldQuad(...points,ground); + if(points.every(p=>p.y===parkDeckY)){flatFrom??=xs[i-1];if(xs[i]-flatFrom>=960)flush(xs[i]);continue;} + const hill=parkHills.findIndex(h=>(x-h.x)**2/h.rx**2+(z-h.z)**2/h.rz**2<1); + flush(xs[i-1]);worldQuad(...points,parkSurfaceColor(hill<0?ground:parkPalette.hills[hill],x,z)); } flush(xs.at(-1)); } @@ -19121,13 +19135,13 @@ function drawPoolGeometry() { } return result; }; - worldQuad({x:bowl.x-bowl.halfX,y:parkDeckY+bowl.radius,z:bowl.z-bowl.halfZ}, - {x:bowl.x+bowl.halfX,y:parkDeckY+bowl.radius,z:bowl.z-bowl.halfZ}, - {x:bowl.x+bowl.halfX,y:parkDeckY+bowl.radius,z:bowl.z+bowl.halfZ}, - {x:bowl.x-bowl.halfX,y:parkDeckY+bowl.radius,z:bowl.z+bowl.halfZ},ink); + worldQuad({x:bowl.x-bowl.halfX,y:parkDeckY+bowl.depth,z:bowl.z-bowl.halfZ}, + {x:bowl.x+bowl.halfX,y:parkDeckY+bowl.depth,z:bowl.z-bowl.halfZ}, + {x:bowl.x+bowl.halfX,y:parkDeckY+bowl.depth,z:bowl.z+bowl.halfZ}, + {x:bowl.x-bowl.halfX,y:parkDeckY+bowl.depth,z:bowl.z+bowl.halfZ},ink); let previous=ring(0); - for(let band=1;band<=12;band++){ - const current=ring(bowl.radius*Math.sin(band/12*Math.PI/2)); + for(let band=1;band<=10;band++){ + const current=ring(bowl.radius*band/10); for(let i=0;i wall); } +let parkBuildingMesh=null; +function drawParkBuildingGeometry(){ + const p=parkPalette,top=parkDeckY-parkBuildingHeight; + const left=gridLeft,right=gridLeft+gridWidth,near=worldNear,far=worldFar; + // Wall panels, frame strips and daylight panes meet at their edges. + // Overlaid trim would fight the wall's depth at shallow viewing angles. + const wall=(ax,az,bx,bz)=>{ + const length=Math.hypot(bx-ax,bz-az),ux=(bx-ax)/length,uz=(bz-az)/length; + const nx=-uz,nz=ux,bays=Math.ceil(length/1100),width=length/bays; + const quad=(l,t,r,b,color,depth=0)=>{ + const at=(u,y)=>({x:ax+ux*u+nx*depth,y,z:az+uz*u+nz*depth}); + worldQuad(at(l,t),at(r,t),at(r,b),at(l,b),color); + }; + for(let i=0;i{ + const a={x:x1,y:y1,z:z1},b={x:x2,y:y1,z:z1},c={x:x2,y:y1,z:z2},d={x:x1,y:y1,z:z2}; + const e={x:x1,y:y2,z:z1},f={x:x2,y:y2,z:z1},g={x:x2,y:y2,z:z2},h={x:x1,y:y2,z:z2}; + worldQuad(a,b,c,d,color);if(!openBottom)worldQuad(e,h,g,f,color);worldQuad(a,e,f,b,color); + worldQuad(b,f,g,c,color);worldQuad(c,g,h,d,color);worldQuad(d,h,e,a,color); + }; + for(const x of [1200,3300,5400,7500])box3(x-20,top,near,x+20,top+65,far,p.frame); + for(const lamp of parkLights){ + for(const dx of [-135,135])box3(lamp.x+dx-3,top+65,lamp.z-3,lamp.x+dx+3,lamp.y,lamp.z+3,p.frame); + box3(lamp.x-200,lamp.y,lamp.z-55,lamp.x+200,lamp.y+28,lamp.z+55,p.frame,true); + const y=lamp.y+28; + worldQuad({x:lamp.x-200,y,z:lamp.z-55},{x:lamp.x+200,y,z:lamp.z-55}, + {x:lamp.x+200,y,z:lamp.z+55},{x:lamp.x-200,y,z:lamp.z+55},p.light); + } +} +function drawParkBuilding(){ + if(!parkBuildingMesh){ + parkBuildingMesh=captureQuadMesh(drawParkBuildingGeometry); + // Light diffusers and daylight glass stay luminous as the view turns. + for(const face of parkBuildingMesh.faces)if(face.color[0]===255||face.color.join(',')===parkPalette.glass.join(',')||face.color.join(',')==='196,228,231') + face.normal=[-globalLight.x,-globalLight.y,-globalLight.z]; + } + drawQuadMesh(parkBuildingMesh); +} function drawRoomSurfaces(left, right, top, bottom, color) { + if(poolOnly()){drawParkBuilding();return;} if (halfpipeOnly()) return; // The station has no room to surface. Every plane this function used to // raise — the plaster sheet behind the fighters, the segmented left wall, diff --git a/xbox/live/tests/xbox-feedback.test.mjs b/xbox/live/tests/xbox-feedback.test.mjs index 6b8b1e7de6..df79cdeb11 100644 --- a/xbox/live/tests/xbox-feedback.test.mjs +++ b/xbox/live/tests/xbox-feedback.test.mjs @@ -39,7 +39,7 @@ function game(course = "pool", retainedDecals = false) { addDecal,decals,rasterDecalPatches,terrainFloorAt,drawDecals, surfaceState:()=>({native:poolDecalsNative,count:poolDecalCount}), poolMesh:()=>captureQuadMesh(drawPoolGeometry), - pool,parkPools,parkHills,parkRamps,parkDeckY,poolFloorAt,updateSkateAudio,buildPoolWalkGeometry,poolDistance,poolSlopeAt,monowheelFrame,runnerWorldGeometry,updateCameraDoll, + pool,parkPools,parkHills,parkRamps,parkDeckY,parkBuildingHeight,boundParkBody,poolFloorAt,updateSkateAudio,buildPoolWalkGeometry,poolDistance,poolSlopeAt,monowheelFrame,runnerWorldGeometry,updateCameraDoll, drawSkateboard,skateFrame,spunSkateFrame, debug:()=>debugHitboxes=true, bounds:()=>({near:worldNear+60,far:worldFar-60}), @@ -370,8 +370,8 @@ test('pool floor rises on every side and through rounded corners', () => { assert.ok(a.terrainFloorAt(x,z)<1770); const corner=a.terrainFloorAt(cx+720,480); assert.ok(corner { @@ -429,7 +429,7 @@ test('raster decals follow pool depth curvature', () => { assert.ok(heights.size>5); }); -test('pool monowheel rides into vert air and returns without passing through the bowl', () => { +test('pool monowheel keeps turbo across banks and landings without entering the ground', () => { for(const heading of [0,Math.PI/2]){ const g=game(),p=g.api.players[0]; Object.assign(p,{onewheel:true,skateboard:true,vx:0,vz:0,skateVx:0}); @@ -441,7 +441,7 @@ test('pool monowheel rides into vert air and returns without passing through the assert.ok(p.y<=g.api.terrainFloorAt(p.x,p.z)+.01); } assert.ok(turbo,'compact pool reaches turbo, including charging through airtime'); - assert.ok(air,'wheel launches off the coping');assert.ok(landed,'rider returns to surface'); + assert.ok(air,'wheel can air off the banks');assert.ok(landed,'rider returns to surface'); } }); @@ -498,31 +498,23 @@ test('A and LB exit either vehicle, preserve it, and allow mounting again', () = } }); -test('a vert air stays over the curve while forward is held and rolls back down on release', () => { - for(const heading of [0,Math.PI/2,Math.PI/4]){ - const g=game(),p=g.api.players[0]; - Object.assign(p,{onewheel:true,skateboard:true,poolYaw:heading});g.api.monowheel.active=false; - g.pads[0].down=['ArrowUp']; - let anchor=null,landed=false,speed=0,returned=false; - for(let i=0;i<650;i++){ - g.tick(); - if(p.poolVert){ - anchor ||= {x:p.x,z:p.z}; - assert.ok(Math.hypot(p.x-anchor.x,p.z-anchor.z)<.001,'no throttle drift onto deck'); - if(p.vy>0)g.pads[0].down=[]; - }else if(anchor&&p.grounded){ - landed=true;speed=Math.max(speed,p.skateVx); - if(g.api.poolDistance(p.x,p.z).d<25){returned=true;break;} - } +test('both vehicles cross every shallow dip rim without locking into a vert return',()=>{ + for(const one of [false,true])for(const index of [0,1,2])for(const heading of [0,Math.PI/2,Math.PI,-Math.PI/2,Math.PI/4]){ + 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:0,vy:0,poolYaw:heading,skateboard:true,onewheel:one,grounded:true}); + a.monowheel.active=false;g.pads[0].down=['ArrowUp'];let exited=false; + for(let i=0;i<200;i++){ + g.tick();assert.ok(!p.poolVert,'gentle banks preserve forward travel'); + assert.ok(p.y<=a.poolFloorAt(p.x,p.z)+.01,'stays above collision surface'); + if(a.poolDistance(p.x,p.z).d>b.radius+10){exited=true;break;} } - assert.ok(anchor&&landed&&returned,'air reconnects to the curved wall and reaches the flat'); - assert.ok(speed>900,'landing carries the fall into rolling speed'); + assert.ok(exited,`vehicle ${one} exits dip ${index} heading ${heading}`); } }); -test('rolling off the coping falls into the pool without a downward teleport', () => { +test('dropping onto a shallow pool edge settles without a downward teleport', () => { const g=game(),p=g.api.players[0]; - Object.assign(p,{x:2044,z:0,y:1320,poolYaw:Math.PI,vx:-700,vz:0,vy:0,onewheel:true,skateboard:true,grounded:true}); + Object.assign(p,{x:2044,z:0,y:g.api.parkDeckY-250,poolYaw:Math.PI,vx:-700,vz:0,vy:0,onewheel:true,skateboard:true,grounded:true}); g.api.monowheel.active=false; let falling=false,landed=false,maxStep=0; for(let i=0;i<160;i++){ @@ -534,24 +526,19 @@ test('rolling off the coping falls into the pool without a downward teleport', ( }); -test('camera swings into a vert return, dips toward the landing, and clears the coping', () => { +test('camera follows bank airs and clears the floor and building', () => { for(const heading of [0,Math.PI/2,Math.PI/4]){ const g=game(),a=g.api,p=a.players[0]; Object.assign(p,{onewheel:true,skateboard:true,poolYaw:heading});a.monowheel.active=false; for(let i=0;i<120;i++)g.tick(); - a.cameraDoll.prepare();const initialPitch=a.cameraDoll.view.forward.y; - let turned=false,dipped=false,previousYaw=null,maxYawStep=0; + a.cameraDoll.prepare(); + let previousYaw=null,maxYawStep=0; g.pads[0].down=['ArrowUp']; for(let i=0;i<350;i++){ g.tick();a.cameraDoll.prepare();const c=a.cameraDoll,v=c.view.forward; const yaw=Math.atan2(v.z,v.x); if(previousYaw!==null)maxYawStep=Math.max(maxYawStep,Math.abs(Math.atan2(Math.sin(yaw-previousYaw),Math.cos(yaw-previousYaw)))); previousYaw=yaw; - if(p.poolVert&&p.vy>0){ - g.pads[0].down=[]; - const dot=(-p.poolVert.nx*v.x-p.poolVert.nz*v.z)/Math.hypot(v.x,v.z); - turned ||= dot>.7;dipped ||= v.y>initialPitch+.04; - } for(const y of [p.y,p.y-190]){ const point=a.projectPoint(p.x,y,p.z); assert.ok(!point.behind&&point.x>20&&point.x<1900&&point.y>20&&point.y<980,`rider frame heading=${heading} tick=${i} x=${point.x} y=${point.y}`); @@ -562,7 +549,7 @@ test('camera swings into a vert return, dips toward the landing, and clears the assert.ok(y{ 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 b of a.parkPools){assert.equal(a.poolFloorAt(b.x,b.z),a.parkDeckY+b.depth);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); @@ -626,3 +613,20 @@ test('both vehicles can ride out through every pool bank and travel across the p assert.ok(outside,`pool ${index} has a usable exit bank`);assert.ok(p.skateboard,'rider stays mounted'); } }); + +test('shallow dips meet the deck smoothly and building collisions contain bodies',()=>{ + const {api:a}=game(); + for(const b of a.parkPools){ + assert.ok(b.depth<=b.radius*.5,'dip depth is less than half its transition width'); + for(const t of [0,.001,.25,.5,.75,.999,1]){ + const x=b.x+b.halfX+b.radius*t,s=a.poolSlopeAt(x,b.z); + assert.ok(Math.hypot(s.x,s.z)<.8,'no near-vertical bank'); + if(t===0||t===1)assert.ok(Math.hypot(s.x,s.z)<.001,'flat contact at floor and rim'); + } + } + for(const [x,z] of [[-100,0],[8800,0],[4000,-3200],[4000,3200]]){ + const p={x,z,y:-4000,vx:100,vy:-1000,vz:100,pad:0};a.boundParkBody(p); + assert.ok(p.x>=25&&p.x<=8615&&p.z>=-2975&&p.z<=2975); + assert.ok(p.y>=a.parkDeckY-a.parkBuildingHeight+230&&p.vy>=0,'head clears the ceiling'); + } +});