diff --git a/xbox/BUILD-RELEASE-PLAN.md b/xbox/BUILD-RELEASE-PLAN.md new file mode 100644 index 0000000000..3b877bb245 --- /dev/null +++ b/xbox/BUILD-RELEASE-PLAN.md @@ -0,0 +1,170 @@ +# Oskiewar build and release plan + +Researched September 27, 2026. Prices are USD before tax. Estimates below are +planning assumptions, not measured performance or promised approval dates. + +**Keep AppVeyor for validation, use shader live reload, and use a persistent +Windows worker when native iteration warrants it.** Paying for another hosted +job helps concurrency; retaining compiler state addresses the repeated work in +our current build. + +[AppVeyor build 499](https://ci.appveyor.com/project/whistlegraph/aesthetic-computer/build/1.0.499) +took approximately 4m46s from submission to completion; +[build 500](https://ci.appveyor.com/project/whistlegraph/aesthetic-computer/build/1.0.500) +took 4m49s. Build 500's timestamped log gives this approximate breakdown: + +| Stage | Time | +| --- | ---: | +| Waiting / starting worker | 66s | +| Checkout, dependency cache, setup | 20s | +| Windows portable tests | 51s | +| GDK desktop build and smoke test | 49s | +| UWP build and packaging | 86s | +| Artifact upload and cache completion | 15s | + +The UWP link alone spends **54 seconds generating optimized code**. It already +uses `/LTCG:incremental`, but fresh workers discard the `.iobj`/`.ipdb` state. +QuickJS is compiled separately for portable tests, GDK desktop, and UWP; +`test-native-windows.cmd` deletes its output directory on every run. The GDK +dependency cache already restores in about two seconds. These are clean native +builds with a warm dependency cache; a fully cold dependency build was not +measured. + +Recommended changes, in order: + +1. Use live JS/shader delivery so those edits avoid native builds. Native build + 52 applied a shader on the Xbox in **85 ms** on September 27. An invalid shader + was rejected while the game kept running; resetting the packaged shader also + passed. This path currently covers the post-processing pixel shader. +2. Publish the Xbox development artifact after portable checks and the UWP + build; run GDK desktop validation outside that artifact's critical path. + Keep full validation required for releases. This removes approximately 49s + of measured waiting from the development path. +3. Preserve incremental compiler/linker state on a persistent worker and stop + deleting portable test outputs. Cache keys must include compiler, flags, + platform, sources and headers; desktop and UWP objects remain separate. +4. Benchmark `/p:PreferredToolArchitecture=x64`; the current UWP log invokes + the 32-bit-hosted tools. Upload the MSIX, x64 dependency and certificate + before the remaining installer files; the current glob uploads 31 files. +5. If necessary, benchmark a development configuration retaining `/O2` while + disabling whole-program optimization. Preserve the release configuration + and verify game performance before adopting the development variant. + +Microsoft documents [incremental LTCG](https://learn.microsoft.com/en-us/cpp/build/reference/ltcg-link-time-code-generation?view=msvc-170) +and [64-bit-hosted build tools](https://learn.microsoft.com/en-us/cpp/build/reference/msbuild-visual-cpp-overview?view=msvc-170). +A 30–90s warm loop for small native edits is a target to test after these +changes, not an existing result. + +| Build option | Cost and tradeoff | +| --- | --- | +| Current AppVeyor | $0 extra; optimize the development path first. | +| AppVeyor Premium | $99/month for two concurrent jobs; potentially $49.50 with an approved OSS discount. Basic $29 and Pro $59 still provide one job. Published priority is technical support, not a faster single-build guarantee. [Pricing](https://www.appveyor.com/pricing/) | +| Existing Windows PC + AppVeyor agent | No additional AppVeyor fee: the free plan includes five self-hosted jobs. Retains installed tools and intermediates. [Worker setup](https://www.appveyor.com/docs/byoc/windows/) | +| AWS Lightsail Windows | $124/month for 4 vCPU, 16GB RAM, 320GB SSD, Windows license included. Burstable CPU suits intermittent builds. Charges continue while stopped. [Bundles](https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-bundles.html), [licensing/performance](https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-faq-instances.html), [billing](https://docs.aws.amazon.com/en_en/lightsail/latest/userguide/amazon-lightsail-frequently-asked-questions-faq-billing-and-account-management.html) | +| Azure Windows D4as_v5 | Approximately $81/month at 176 running hours, or $278 at 730 hours, including a P10 disk. Official retail API queried September 27: West US 2 Windows compute $0.356/hour; P10 LRS disk $17.92/month. Networking extra. Deallocate between sessions to stop compute billing while retaining disk. [Price API](https://learn.microsoft.com/en-us/rest/api/cost-management/retail-prices/azure-retail-prices), [size](https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/general-purpose/dasv5-series), [billing states](https://learn.microsoft.com/en-us/azure/virtual-machines/states-billing) | +| DigitalOcean | Not a supported Windows Droplet path. Paperspace Windows templates are unavailable to new accounts since July 2024. [Droplet policy](https://docs.digitalocean.com/support/can-i-use-windows-on-a-droplet/), [Paperspace policy](https://docs.digitalocean.com/products/paperspace/machines/how-to/connect/) | + +Also benchmark GitHub Actions if its recorded billing lock is cleared: public +Windows runners provide 4 CPU/16GB at no charge. They still start fresh, so do +not provide persistent incremental state. +[GitHub runner specifications](https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job) + +**Steam is the shorter release path: approximately 3–5 weeks with a fixed launch +scope. The partner account is cleared; the game still needs store/build review.** + +[STEAM.md](steam/STEAM.md) records the $100 fee paid September 1, app ID +5280790 assigned September 15, a packaged Electron shell, and Windows/macOS/Linux +depot setup. [The fill sheet](steam/store-page/fill.md) records the store page +filled, trailer uploaded September 16, and $9.99 price chosen. + +A read-only check of the authenticated Steamworks dashboard on September 27 +supersedes the older account notes: + +| Steam gate | Verified state | +| --- | --- | +| Partner account / tax | Full partner dashboard available; company page explicitly says the organization's tax information is verified. | +| Pricing permission | The package pricing form opens normally. The old payee restriction is gone. No price is currently set, approved or published; zero pending changes. | +| Store review | Store Presence checklist complete, but still offers “Mark as ready for review…”; this is ready to submit, not approved. | +| Game build | Checklist incomplete: no configured build, platform support mismatch, and missing package pricing / published pricing. | +| Public release | App unavailable; store package hidden. Public Coming Soon has not started. October 15 is the configured target date, not a cleared release date. | + +Evidence: [app dashboard](https://partner.steamgames.com/apps/landing/5280790) +and [package pricing](https://partner.steamgames.com/packages/pricing/1827387) +(account access required). No values were edited and no review was submitted. + +The existing shell needs maintenance before upload. A read-only check of its +`trim()` function against today's `mac-test.html` fails at the +`__oskiewarVersusCapable` replacement. Its README records macOS boot verification, +but Windows/Linux were only cross-built and still need target-system tests. +Using this shell avoids waiting for the native Xbox renderer port. Store assets +and feature claims must match whichever version we choose to release. + +Valve requires **14 days of public Coming Soon**. Store-page and build reviews +typically take 3–5 business days each; Valve asks for at least seven business +days of allowance. Build preparation and review can overlap the Coming Soon +window. October 15 is possible only if Coming Soon is public by October 1 and +the other gates clear; mid-to-late October is a more practical conditional +target. [Coming Soon](https://partner.steamgames.com/doc/store/coming_soon), +[review process](https://partner.steamgames.com/doc/store/review_process) + +There is a current source discrepancy: Valve's English +[onboarding page](https://partner.steamgames.com/doc/gettingstarted/onboarding) +says **21 days** after paying the app fee, while +[Steam Direct](https://partner.steamgames.com/steamdirect) still says **30**. +The authenticated Oskiewar dashboard explicitly applies **21 days since the +first app-credit purchase**, resolving which rule this account currently sees. +The recorded September 1 payment is already more than 21 days old; the public +Coming Soon requirement remains the material waiting period. The per-product +fee remains $100, recoupable after +$1,000 adjusted gross revenue. [Fee terms](https://partner.steamgames.com/doc/gettingstarted/appfee) + +**Retail Xbox needs a separate engineering effort: budget 8–12 weeks or more, +with program access and devkit timing unresolved.** Today's Dev Mode deployment +is already useful for testing, but Microsoft says new UWP games are no longer +accepted in the Xbox Store. +[UWP guidance](https://learn.microsoft.com/en-us/windows/uwp/gaming/getting-started) + +The GDK desktop smoke test now passes, but it is not a console game build. The +remaining work includes the D3D11-to-D3D12.x renderer/text port, platform APIs, +Xbox users and achievements, lifecycle handling, packaging and hardware tests. +The game source is now bundled in the native package; the older claim in +`PUBLISHING.md` that only the smoke piece ships is obsolete. Dev live-reload +facilities remain development-only. A narrow local-only launch bounds the work. +[Console graphics requirements](https://learn.microsoft.com/en-us/gaming/gdk/docs/gdk-dev/intro/introduction), +[console SDK access](https://github.com/microsoft/GDK) + +Microsoft account approval has positive evidence too: July 18 emails titled +“Welcome to Windows Dev Center” and “We've verified your profile and you may +start exploring Partner Center” confirm the Aesthetic Computer publisher +account was ready and its Partner Center profile verified. These messages were +read from the existing mail archive, which contains mail through September 27. + +ID@Xbox program/concept/title approval and console GDK access remain +**unverified**, rather than known absent. On September 27, both the Xbox partner +portal and Partner Center redirected the existing signed-in account to a +“We're updating our terms” Microsoft Services Agreement screen. No terms were +accepted. A narrow search of the relevant mail accounts found the account +approvals above, but no ID@Xbox concept approval notice. The next check is the +title's status in those portals after the account owner completes that screen; +Dev Mode registration alone does not establish retail-title approval. + +ID@Xbox application is free. Microsoft lists concept +decisions at 10–15 business days, account verification usually 3–5 business days, +and dev-store setup usually five; engineering can proceed alongside onboarding. +Once submission-ready, digital-final certification has a published **four +business day SLA per submission**. Failed submissions require fixes and another +pass; that SLA is not a release-date promise. +[Onboarding](https://learn.microsoft.com/en-us/gaming/game-publishing/onboarding/overview), +[certification](https://learn.microsoft.com/en-us/gaming/game-publishing/concepts/certification/certification-guide) + +Do not describe Xbox Creators as definitively discontinued: current Store +policy still names it, while current UWP guidance rejects its historical +package type. A usable enrollment shortcut was not verified. Plan around +ID@Xbox/GDK unless Microsoft confirms another route for this title. +[Current Store policy](https://learn.microsoft.com/en-us/windows/apps/publish/store-policies#1013-gaming-and-xbox) + +The practical sequence is to prepare the Steam launch build and start its public +Coming Soon clock, verify the existing Xbox program/title status in parallel, +and continue immediate Xbox testing through Dev Mode. No services were purchased +or provisioned, no applications submitted, and no messages sent during this +research. diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 04c531d9da..c21ffc819f 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 = 174; +const buildVersion = 175; 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 @@ -168,9 +168,9 @@ const ceilingY = floorY - gridHeight - wallThickness; // also push the far plane twice as far out, because every camera stand-off, // every killcam orbit and the whole ±30000 native coordinate budget were // tuned against this depth and not against the floor's span. -const roomDepth = 900; -const worldNear = -roomDepth / 2; -const worldFar = roomDepth / 2; +let roomDepth = 900; +let worldNear = -roomDepth / 2; +let worldFar = roomDepth / 2; // Address helpers: world position to tile and back. Rows count UP from the // floor, because in a fight the floor is where everything starts. All three // clamp, so a body pressed into a wall still names a real tile. @@ -314,7 +314,8 @@ const indoorFeatures = [ // enough that the room reads as a room. A head that reaches it bonks. const indoorCeilingY = floorY - 1400; const indoorSkate = () => skateparkMap && skateCourse === "indoor"; -const halfpipeOnly = () => skateparkMap && skateCourse === "halfpipe"; +const poolOnly = () => skateparkMap && skateCourse === "pool"; +const halfpipeOnly = () => skateparkMap && (skateCourse === "halfpipe" || poolOnly()); const depthControls = () => halfpipeOnly() && freeskateActive() && globalThis.__oskiewarDepthControls !== false; // The roof is glass: eight panes in a frame. A slow knock bonks you back @@ -412,7 +413,9 @@ function configureWorldMap(name, course = skateCourse) { if (name !== "skatepark" && !skateparkMap) return; const previousMap = skateparkMap + "/" + skateCourse; skateparkMap = name === "skatepark"; - skateCourse = course === "halfpipe" ? "halfpipe" : course === "indoor" ? "indoor" : "park"; + skateCourse = course === "pool" ? "pool" : course === "halfpipe" ? "halfpipe" : course === "indoor" ? "indoor" : "park"; + roomDepth = poolOnly() ? 1640 : 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. if (previousMap !== skateparkMap + "/" + skateCourse) { @@ -863,7 +866,7 @@ function rasterDecalPatches(decal) { z = clamp(z, worldNear+1, worldFar-1); // Sample every corner against the curved ramp; a tangent-plane sticker // would float over the curve or cut into it at its edges. - return {x,y:terrainFloorAt(x)-.9,z}; + return {x,y:terrainFloorAt(x,z)-.9,z}; }; const patches = [], across = arc ? 8 : 4, deep = arc || skid ? 1 : 2; for (let i=0;i>> 0) / 4294967296 * Math.PI * 2; } -function terrainFloorAt(x) { +// 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 }; +} +function terrainFloorAt(x, z = 0) { + if (poolOnly()) return poolFloorAt(x, z); // Outside the park the ground is flat, so the walls have something square // to stand out of and a body pushed past the lip still has a floor. if (x <= parkLeft) return floorY - parkSegments[0].lift - (parkSegments[0].rise || 0); @@ -1733,7 +1756,7 @@ const platformY = floorY; // it, and the floor when there is no rung. Shadows, pickups and the bot all // ask this, so a rung that moves takes its furniture with it. function surfaceYAt(x, y, z = 0) { - let surface = terrainFloorAt(x); + let surface = terrainFloorAt(x, z); if (platformsEnabled()) for (const ledge of activeLedges(z)) if (ledge.y < surface && ledge.y >= y - 1 && x >= ledge.left && x <= ledge.right) surface = ledge.y; @@ -6148,7 +6171,7 @@ function dressFreeskater(rider, advance = false) { function freeskateCourse() { const requested = String(globalThis.__oskiewarFreeskateMap || "").trim().toLowerCase(); return requested === "skatepark" || requested === "park" ? "park" - : requested === "indoor" ? "indoor" : "halfpipe"; + : requested === "indoor" ? "indoor" : requested === "halfpipe" ? "halfpipe" : "pool"; } // Indoors you start on foot beside your one board; the long park still @@ -6229,7 +6252,7 @@ function freeskateRequested() { return String(globalThis.__oskiewarOpponent || "").trim().toLowerCase() === "freeskate"; } -// Freeskate starts with one rider beside a monowheel in the compact halfpipe. +// Freeskate starts with one rider beside a monowheel in the empty swimming pool. // The existing hall and long park remain explicit host options. function beginFreeskate(now) { freeskateSecond = false; @@ -6281,6 +6304,7 @@ function beginFreeskate(now) { placeFreeskateBoard(); } ensureFreeskateBoards(); + if(poolOnly()){rider.poolYaw=0;poolCameraYaw=0;} resetMonowheel(); resetParkSupply(now); resetParkKids(); @@ -9098,6 +9122,7 @@ function resetRound(now, resetMatch = false, keepMap = false) { if (halfpipeOnly()) { resetSkate(players[0]); players[0].wheelMarkAt = null; + if(poolOnly()){players[0].poolYaw=0;poolCameraYaw=0;} seedHalfpipeScuffs(); for (const item of balls) item.active = false; resetMonowheel(); @@ -9362,7 +9387,23 @@ function deathOrbitShot(target, width, progress) { perspective: .65, fov: 55, roll: Math.sin(phase) * .035 }; } +let poolCameraYaw = 0; function updateCameraDoll(dt, now) { + if (poolOnly() && freeskateActive()) { + const p = players[0], heading = p.poolYaw || 0; + poolCameraYaw += Math.atan2(Math.sin(heading-poolCameraYaw), Math.cos(heading-poolCameraYaw)) * + (1-Math.exp(-dt*1.8)); + const angle = poolCameraYaw + .65 + playerCameraYaw; + const zoom = playerCameraZoom, distance = 1350 * zoom; + const target = { x:p.x+clamp(p.vx*.1,-160,160), y:p.y-85, + z:(p.z||0)+clamp((p.vz||0)*.1,-160,160) }; + const position = { x:target.x-Math.cos(angle)*distance, + y:Math.min(target.y-1050*zoom-playerCameraPitch*600, floorY-pool.radius-300), + z:target.z-Math.sin(angle)*distance }; + cameraCenter=target.x; cameraCenterY=target.y; cameraWidth=1650*zoom; + cameraDoll.track({target,position,width:cameraWidth,perspective:1,fov:58,roll:0},dt,8); + return; + } if(freeskateActive() && parkSupply.ko){ const result=parkSupply.ko,subject=players[(now-result.at)<1000000?result.loser:(result.winner<0?0:result.winner)]; const age=(now-result.at)/1e6,center={x:subject.x,y:subject.y-100,z:subject.z||0}; @@ -12046,7 +12087,92 @@ function updateTurn(player, now) { } } +function updatePoolPlayer(p, pad, dt, now) { + const held=pad.down, pressed=key=>held.includes(key)&&!p.previous.includes(key); + const axis=value=>Math.abs(value)>.18?Math.sign(value)*(Math.abs(value)-.18)/.82:0; + let ix=Number(held.includes('ArrowRight'))-Number(held.includes('ArrowLeft')) || axis(pad.leftX||0); + let iz=Number(held.includes('ArrowUp'))-Number(held.includes('ArrowDown')) || axis(pad.leftY||0); + const inputLength=Math.hypot(ix,iz); if(inputLength>1){ix/=inputLength;iz/=inputLength;} + p.previousY=p.y; p.inputX=ix; p.inputZ=iz; p.inputY=held.includes('X')?-1:held.includes('A')?1:0; + p.ducking=held.includes('X'); p.crouchBlend+=(Number(p.ducking)-(p.crouchBlend||0))*(1-Math.exp(-dt*14)); + p.facing=1; p.turnAt=-1e12; p.footSurf=false; p.strafe=null; p.grabHeld=false; + if(!held.includes('RightShoulder'))p.shieldLocked=false; + const blocking=held.includes('RightShoulder')&&!p.shieldLocked; + if(blocking&&!p.blocking)playBubbleSound(p,false); + if(!blocking&&p.blocking)playBubbleSound(p,true); + p.blocking=blocking; p.shieldCrouched=p.ducking; + if(now>=p.attackUntil)p.attackKind=''; + if(!blocking){if(pressed('B'))startMelee(p,'PUNCH',now);if(pressed('Y'))startMelee(p,'KICK',now);} + if(pressed('A')&&p.grounded){ + p.vy=Math.min(0,p.vy)-jumpVelocity; p.grounded=false; + p.jumpPoseUntil=now+240000; p.jumpLaunchAt=0; p.jumpHeld=true; + emitSignal('jump',p.pad,0,0); + } + // Substep contacts in both horizontal axes, keeping speed tangent to the bowl. + const steps=Math.max(1,Math.ceil(dt*120)), step=dt/steps; + for(let n=0;n0){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=floor){ + slope=poolSlopeAt(p.x,p.z); + const inward=(p.vy-slope.x*p.vx-slope.z*p.vz)/(1+slope.x*slope.x+slope.z*slope.z); + if(inward>0){p.vx+=slope.x*inward;p.vz+=slope.z*inward;} + p.landingSpeed=p.vy;p.y=floor;p.vy=slope.x*p.vx+slope.z*p.vz; + p.grounded=true;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;} + } + } + const horizontal=Math.hypot(p.vx,p.vz||0); + if(horizontal>30){ + const desired=Math.atan2(p.vz,p.vx),heading=p.poolYaw||0; + p.poolYaw=heading+Math.atan2(Math.sin(desired-heading),Math.cos(desired-heading))*(1-Math.exp(-dt*12)); + } + 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.onewheel?Math.hypot(p.vx,p.vy,p.vz||0):0; + 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(); +} function updatePlayer(player, pad, dt, now) { + if(poolOnly()&&freeskateActive()&&player.alive&&!player.dummy){updatePoolPlayer(player,pad,dt,now);return;} if(player.dummy){ const previous=player.x; player.vx=player.knockVx||0;player.knockVx*=Math.exp(-dt*4); @@ -14719,6 +14845,13 @@ function runnerWorldGeometry(player, t) { segment.x1 = a.x; segment.y1 = a.y; segment.x2 = b.x; segment.y2 = b.y; } } + if (poolOnly() && player.poolYaw) { + const c=Math.cos(player.poolYaw),s=Math.sin(player.poolYaw),z=player.z||0; + const rotate=(point,xKey,zKey)=>{const dx=point[xKey]-player.x,dz=(point[zKey]??z)-z; + point[xKey]=player.x+dx*c-dz*s;point[zKey]=z+dx*s+dz*c;}; + rotate(pose.head,'x','z'); + for(const bone of pose.segments){rotate(bone,'x1','z1');rotate(bone,'x2','z2');} + } if (sharingRenderPoses) renderPoses.set(player, { t, pose }); return pose; } @@ -14753,7 +14886,7 @@ function updateRig(player, pad, dt) { const held = pad?.down || []; const stick = board ? (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("ArrowDown"); + const pumping = board && (pad?.down || []).includes(depthControls()?"X":"ArrowDown"); if (player.grounded && !rig.lastGrounded) { rig.crouchV = (rig.crouchV || 0) + clamp((player.landingSpeed || 900) / 1200, .4, 2.4) * 3; if (!board && (player.landingSpeed || 0) > 2400) addDing(player.x, player.z || 0, 4 + player.landingSpeed / 900); @@ -14906,7 +15039,7 @@ function buildRunnerWorldGeometry(player, t, at = null) { const animation = fighterAnimationPhase(player, at); const poseNow = animation.frameNow; const poseCycle = animation.progress * Math.PI * 2; - const speed = Math.min(1, Math.abs(player.vx) / 1500); + const speed = Math.min(1, (poolOnly()?Math.hypot(player.vx,player.vz||0):Math.abs(player.vx)) / 1500); const meditating = shellMode === "MENU" && titleTransitionAt === null && titleAttractMode === "still"; const idle = player.grounded && !player.ducking && speed < .03; @@ -16034,7 +16167,7 @@ function skateContact(x, y, z = 0) { } function skateFrame(player) { - if (player.onewheel) return monowheelFrame(player); + if (player.onewheel) return monowheelFrame(player, true); // In a spin the board turns on the spot with the rider. const yaw = player.spin ? player.spin.angle : 0; const yawCos = Math.cos(yaw), yawSin = Math.sin(yaw); @@ -18703,8 +18836,47 @@ function terrainSurfaceShades(color) { } return terrainShades; } +let poolSurfaceMesh=null; +function drawPoolGeometry() { + const center=gridLeft+gridWidth/2,segments=12; + 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; + }; + // 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=bands-2&&band<=bands; + const shade=band>bands?[216,221,211]:tile?(i%2?[37,117,151]:[49,139,167]): + (i+band)%2?[153,197,199]:[167,210,211]; + worldQuad(previous[i],previous[j],current[j],current[i],shade); + } + previous=current; + } + for(let i=0;idrawTerrainSurfaceGeometry(worldLeft,worldRight,near,far,color));} @@ -18729,6 +18901,7 @@ function drawTerrainSurfaceGeometry(left, right, near, far, color) { let pipeWallMesh=null,pipeWallKey=''; function drawTerrainFrontWall(left,right,near,color){ + if(poolOnly())return; if(!indoorSkate()){drawTerrainFrontWallGeometry(left,right,near,color);return;} const key=gridCols+':'+near+':'+color.join(','); if(key!==pipeWallKey){pipeWallKey=key;releaseQuadMesh(pipeWallMesh);pipeWallMesh=captureQuadMesh(()=>drawTerrainFrontWallGeometry(worldLeft,worldRight,near,color));} @@ -18772,6 +18945,7 @@ function drawTerrainFrontWallGeometry(left, right, near, color) { } function drawTerrainBackWall(left, right, far, color) { + if(poolOnly())return; if(indoorSkate())return; // The far edge was simply open. Yaw clamps at +/-.62rad, which is more than // enough to swing the diorama around and look straight through the back of @@ -19031,8 +19205,8 @@ function drawSkyAtmosphere(sky, arena) { } } -function shadowSurfaceY(x, y) { - return surfaceYAt(x, y); +function shadowSurfaceY(x, y, z = 0) { + return surfaceYAt(x, y, z); } // Project the existing pose onto the receiving surface: no shadow map, @@ -19050,7 +19224,7 @@ function shadowSurfaceY(x, y) { // onto a curved floor point by point cost the Xbox three milliseconds a // frame; the web and Mac hosts keep the pose-shaped shadow. function drawBlobShadow(player, color) { - const floor = shadowSurfaceY(player.x, player.y); + const floor = shadowSurfaceY(player.x, player.y, player.z||0); const height = Math.max(0, floor - player.y); const fade = clamp(1 - height / 900, .15, 1); const radiusX = 62 * (.55 + .45 * fade), radiusZ = radiusX * .42; @@ -19059,9 +19233,9 @@ function drawBlobShadow(player, color) { for (let side = 0; side < 10; side++) { const a = side / 10 * Math.PI * 2, b = (side + 1) / 10 * Math.PI * 2; worldTriangle(center, - { x: player.x + Math.cos(a) * radiusX, y: shadowSurfaceY(player.x + Math.cos(a) * radiusX, floor) - 2, + { x: player.x + Math.cos(a) * radiusX, y: shadowSurfaceY(player.x + Math.cos(a) * radiusX, floor, player.z + Math.sin(a) * radiusZ) - 2, z: player.z + Math.sin(a) * radiusZ }, - { x: player.x + Math.cos(b) * radiusX, y: shadowSurfaceY(player.x + Math.cos(b) * radiusX, floor) - 2, + { x: player.x + Math.cos(b) * radiusX, y: shadowSurfaceY(player.x + Math.cos(b) * radiusX, floor, player.z + Math.sin(b) * radiusZ) - 2, z: player.z + Math.sin(b) * radiusZ }, ink); } } @@ -19073,10 +19247,11 @@ function drawPoseShadow(player, t, color) { // cost it three milliseconds, not the shape. const lite = consoleHost(); const world = player.replayGeometry || player.frozenGeometry || runnerWorldGeometry(player, t); - const surface = shadowSurfaceY(player.x, player.y); + const surface = shadowSurfaceY(player.x, player.y, player.z||0); const behindFighter = projectPoint(player.x, player.y, player.z).z + .004; const floorCache = new Map(); - const floorAt = (x) => { + const floorAt = (x, z) => { + if (poolOnly()) return poolFloorAt(x,z); if (!lite) return shadowSurfaceY(x, surface - 5); const key = Math.round(x / 10); let y = floorCache.get(key); @@ -19087,7 +19262,7 @@ function drawPoseShadow(player, t, color) { const height = Math.max(0, surface - y); const sx = clamp(x + globalLight.x / globalLight.y * height, worldLeft, worldRight); const sz = clamp(z + globalLight.z / globalLight.y * height, worldNear + 4, worldFar - 4); - const point = projectPoint(sx, floorAt(sx) - 2, sz); + const point = projectPoint(sx, floorAt(sx,sz) - 2, sz); // On the floor's own surface, just above it. Fighters now stand at the // floor's near edge, so a shadow there is still behind them — and it is // no longer buried under the near half of the floor. @@ -19177,7 +19352,7 @@ function updateSceneLighting(now) { } function drawSpotShadow(x, y, z, radius, color) { - const surfaceY = shadowSurfaceY(x, y); + const surfaceY = shadowSurfaceY(x, y, z); const height = Math.max(0, surfaceY - y); const reach = Math.min(520, height * .22); const focus = clamp(1 - height / 5200, .42, 1); @@ -21486,9 +21661,11 @@ function resetMonowheel(){ const x=halfpipeOnly()?clamp(players[0].x+170,gridLeft+60,gridLeft+gridWidth-60):tileCenterX(37); Object.assign(monowheel,{active:true,x,y:terrainFloorAt(x),z:0,vx:0,vy:0,safeUntil:0}); } -function monowheelFrame(p){ +function monowheelFrame(p, local=false){ const pitch=p.skatePitch||0,c=Math.cos(pitch),s=Math.sin(pitch); - return (x,y=0,z=0)=>({x:p.x+c*x-s*y,y:p.y-24+s*x+c*y,z:(p.z||0)+z}); + const yaw=!local&&poolOnly()?(p.poolYaw||0):0,cy=Math.cos(yaw),sy=Math.sin(yaw); + return (x,y=0,z=0)=>{const along=c*x-s*y;return { + x:p.x+along*cy-z*sy,y:p.y-24+s*x+c*y,z:(p.z||0)+along*sy+z*cy};}; } function riderContact(p,y){ if(!p.onewheel)return skateContact(p.x,y,p.z); @@ -21586,7 +21763,7 @@ function playerFloor(p,x){ if(p.underPipe)return tunnelBottom(); const i=bottomPaneAt(x); if(indoorSkate() && i>=0 && i=2940 || (!p.grounded && p.wheelChargeAt)){ + if(speed>=(poolOnly()?1900:2940) || (!p.grounded && p.wheelChargeAt)){ p.wheelChargeAt ||= now; if(now-p.wheelChargeAt>=1000000*gameSpeed){p.wheelTurbo=true;p.lastButton='SUPER TURBO';p.lastButtonAt=now;playDrum('whoosh',.6,panPlayer(p));} }else p.wheelChargeAt=0; diff --git a/xbox/live/tests/xbox-feedback.test.mjs b/xbox/live/tests/xbox-feedback.test.mjs index 7c710954d8..a7a46b1418 100644 --- a/xbox/live/tests/xbox-feedback.test.mjs +++ b/xbox/live/tests/xbox-feedback.test.mjs @@ -4,11 +4,12 @@ import vm from 'node:vm'; import test from 'node:test'; const source = await readFile(new URL('../oskiewar.js', import.meta.url), 'utf8'); -function game() { +function game(course = "pool") { let now = 0; const post = [], texts = [], faces = [], sounds = [], decalsDrawn = []; const pads = [0, 1].map(() => ({ connected: true, down: [], leftX: 0, leftY: 0 })); const context = vm.createContext({ + __oskiewarFreeskateMap: course, runtime: () => ({ monotonicUs: now, unixMs: 1785870000000 + now / 1000, simCount: Math.floor(now / 16667), paintCount: 0, renderAlpha: 1 }), gamepad: (i = 0) => pads[i], @@ -30,6 +31,7 @@ function game() { cameraDoll,steerPostEffects,projectPoint,drawSeatPlayerHud,handleWidth, breakRoofPane,configureWorldMap,resetRoofPanes,updatePlayer,updateWheelTurbo, addDecal,decals,rasterDecalPatches,terrainFloorAt,drawDecals, + pool,poolDistance,poolSlopeAt,monowheelFrame,runnerWorldGeometry,updateCameraDoll, debug:()=>debugHitboxes=true, bounds:()=>({near:worldNear+60,far:worldFar-60}), classic:()=>globalThis.__oskiewarDepthControls=false, @@ -53,7 +55,7 @@ function game() { test('default course stays one rider and one monowheel through ticks, joins and resets', () => { const g = game(), a = g.api; const check = () => { - assert.equal(a.map().course, 'halfpipe'); + assert.equal(a.map().course, 'pool'); assert.equal(a.map().cols, 24); assert.equal(a.map().width, 2160); assert.equal(a.map().features.length, 5); @@ -125,7 +127,7 @@ test('BPM is large, centered with its heart, and shadowed on the HUD depth', () }); test('halfpipe uses continuous depth movement, bounded at both ramp edges', () => { - const g = game(), p = g.api.players[0], pad = g.pads[0]; + const g = game("halfpipe"), p = g.api.players[0], pad = g.pads[0]; const y = p.y; pad.leftY = .7; for (let i = 0; i < 10; i++) g.tick(); @@ -167,7 +169,7 @@ test('A jumps, X crouches, B punches, Y kicks without former button actions', () test('bubble keeps ground steering and wheel momentum in both control layouts', () => { for (const classic of [false,true]) for (const riding of [false,true]) { - const g = game(), p = g.api.players[0]; + const g = game(classic ? "halfpipe" : "pool"), p = g.api.players[0]; if (classic) g.api.classic(); Object.assign(p, {x:950,y:1800,vx:riding?1700:880,grounded:true, skateboard:riding,onewheel:riding,skateVx:riding?1700:0}); @@ -210,7 +212,7 @@ test('monowheel turbo charges, persists through airtime, then drops on a slow la }); test('raster stamps conform to the curved halfpipe and depth-test as transparent world quads', () => { - const g=game(),a=g.api; + const g=game("halfpipe"),a=g.api; vm.runInContext('cameraCenter=380;cameraWidth=1650',g.context); a.cameraDoll.snap({target:{x:380,y:1550,z:0},position:{x:380,y:800,z:-2200}, width:1650,perspective:0,fov:55,roll:0}); @@ -243,3 +245,78 @@ test('a glass roof break is one composite cue and still works on the full hall', assert.equal(sounds.length,1); assert.equal(sounds[0][0],'glass'); }); + + +test('pool floor rises on every side and through rounded corners', () => { + const {api:a}=game(),cx=1080; + assert.equal(a.terrainFloorAt(cx,0),1800); + for(const [x,z] of [[cx+720,0],[cx-720,0],[cx,480],[cx,-480]]) + assert.ok(a.terrainFloorAt(x,z)<1770); + const corner=a.terrainFloorAt(cx+720,480); + assert.ok(corner { + const g=game(),p=g.api.players[0]; + Object.assign(p,{onewheel:true,skateboard:true,vx:0,vz:0,skateVx:0}); + g.api.monowheel.active=false;g.pads[0].down=['ArrowUp']; + for(let i=0;i<30;i++)g.tick(); + assert.ok(p.z>20);assert.ok(p.poolYaw>1.3); + const frame=g.api.monowheelFrame(p),front=frame(50),rear=frame(-50); + assert.ok(front.z-rear.z>80,'wheel points through depth'); + const pose=g.api.runnerWorldGeometry(p,1); + assert.ok(Number.isFinite(pose.head.z)); + assert.ok(Math.abs(pose.head.z-p.z)>3,'body turns into movement'); +}); + +test('pool movement stays on the surface in all four directions and corners', () => { + for(const down of [['ArrowRight'],['ArrowLeft'],['ArrowUp'],['ArrowDown'],['ArrowUp','ArrowRight']]){ + const g=game(),p=g.api.players[0];g.pads[0].down=down; + for(let i=0;i<450;i++){ + 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'); + } + } +}); + +test('pool camera follows depth and height with perspective', () => { + const {api:a}=game(),p=a.players[0]; + Object.assign(p,{x:1400,y:1100,z:420,vx:0,vz:0,poolYaw:Math.PI/2}); + for(let i=0;i<180;i++)a.updateCameraDoll(1/60,1000000+i*16667); + assert.ok(Math.abs(a.cameraDoll.target.x-p.x)<1); + assert.ok(Math.abs(a.cameraDoll.target.z-p.z)<1); + assert.ok(Math.abs(a.cameraDoll.target.y-(p.y-85))<1); + assert.ok(a.cameraDoll.perspective>.999); + assert.ok(a.cameraDoll.position.z { + const {api:a}=game(); + const mark=a.addDecal({kind:'scuff',x:1080,z:540,size:80,angle:.3}); + const patches=a.rasterDecalPatches(mark),heights=new Set(); + for(const patch of patches)for(const p of patch.points){ + assert.ok(Math.abs(p.y-a.terrainFloorAt(p.x,p.z)+.9)<.001); + heights.add(Math.round(p.y)); + } + assert.ok(heights.size>5); +}); + +test('pool monowheel rides into vert air and returns without passing through the bowl', () => { + for(const direction of ['ArrowRight','ArrowUp']){ + const g=game(),p=g.api.players[0]; + Object.assign(p,{onewheel:true,skateboard:true,vx:0,vz:0,skateVx:0}); + g.api.monowheel.active=false;g.pads[0].down=[direction]; + let air=false,landed=false,maxSpeed=0,turbo=false; + for(let i=0;i<900;i++){ + g.tick();maxSpeed=Math.max(maxSpeed,p.skateVx);turbo ||= p.wheelTurbo; + if(!p.grounded)air=true;else if(air)landed=true; + 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'); + } +});