From 373c028b899be3b3605974d694e29cb54fb92ce9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 22:11:03 -0700 Subject: [PATCH] One control layout everywhere, SDF bodies and a shaded bubble on Metal Pads: A kick (the gas on a board or kart), X punch, Y the other hand's punch, B jump, A+X together the bubble. translateButtons maps the physical pad onto each mode's internal buttons at samplePad, in live play only, with a two-sample chord window so a bubble never starts with a stray kick; menus keep A confirm / B back. Keyboard: Q and E punch, Shift kicks, Space jumps, Shift+Q is the bubble, R drops the held item, arrows alias WASD; player two moves to IJKL with U O ; ' as A B X Y. Tab alone toggles debug. The park hints are keycaps or pad discs with one short verb each, and the top status reads blocking, punching and kicking. The butterfly beside the room name is gone. Metal (Mac and iOS) gains the sceneApi 3 pass: SdfFigures.swift ports the Xbox SDF shader, and the loose cartoon runner sends its body as round cones while keeping its drawn head, face and cloth. The shield bubble is now an analytic sphere with a fresnel rim, thin-film colour and a glint. Co-Authored-By: Claude Opus 5.5 --- apple/oskiewar-mac/project.yml | 1 + .../Oskiewar.xcodeproj/project.pbxproj | 4 + apple/oskiewar/Sources/MetalSceneView.swift | 64 ++++ apple/oskiewar/Sources/SdfFigures.swift | 280 ++++++++++++++++++ xbox/live/mac-test.html | 19 +- xbox/live/oskiewar.js | 138 ++++++--- xbox/live/tests/oskiewar.test.mjs | 2 + xbox/live/tests/pool-playground.test.mjs | 27 +- xbox/macos-native/main.swift | 63 +++- xbox/tools/build-macos-app.sh | 1 + 10 files changed, 538 insertions(+), 61 deletions(-) create mode 100644 apple/oskiewar/Sources/SdfFigures.swift diff --git a/apple/oskiewar-mac/project.yml b/apple/oskiewar-mac/project.yml index eb356a0bc7..6d28ae54a1 100644 --- a/apple/oskiewar-mac/project.yml +++ b/apple/oskiewar-mac/project.yml @@ -60,6 +60,7 @@ targets: # as the Sources/ directory so OskiewarApp.swift (the iOS @main) can't # come along and collide with main.swift. - path: ../oskiewar/Sources/MetalSceneView.swift + - path: ../oskiewar/Sources/SdfFigures.swift - path: ../oskiewar/Sources/GlyphAtlas.swift # NativeGameHost.start() reads these back as "live/oskiewar.js" and diff --git a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj index f6dae4692c..f37964136b 100644 --- a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj +++ b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 82A4DA7DFB26C3E87F9AB609 /* oskiewar.js in Resources */ = {isa = PBXBuildFile; fileRef = AB2FCD0A93F17F907A54302C /* oskiewar.js */; }; 92EDCBE6EFA5565DACCF8DC1 /* frame-vm.mjs in Resources */ = {isa = PBXBuildFile; fileRef = 06F9EDFF9B652891C2F4DB20 /* frame-vm.mjs */; }; 98991A7AC85B7CAE426E8D15 /* qr.mjs in Resources */ = {isa = PBXBuildFile; fileRef = A50E50A82D3F715FB8B31490 /* qr.mjs */; }; + B2694D04A86822B4F7712ABD /* SdfFigures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A6D78CBFE2DD45EEA57 /* SdfFigures.swift */; }; B93964796DBF64A171DD9439 /* GlyphAtlas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59BD48D8076F347B7FF9A774 /* GlyphAtlas.swift */; }; C0C46FBB6805F1A1E055FED7 /* OskiewarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */; }; DE990881EAE44FB2BE4592FF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FCECE2875B24DB7356CB0FDC /* Assets.xcassets */; }; @@ -35,6 +36,7 @@ 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */ = {isa = PBXFileReference; path = "oskiewar-sfx.mjs"; sourceTree = ""; }; 474C7E2A33BAAEDAEED939DF /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4C7C4D62E6C706C3AB30BB3B /* frame-driver.mjs */ = {isa = PBXFileReference; path = "frame-driver.mjs"; sourceTree = ""; }; + 4E537A6D78CBFE2DD45EEA57 /* SdfFigures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SdfFigures.swift; sourceTree = ""; }; 59BD48D8076F347B7FF9A774 /* GlyphAtlas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlyphAtlas.swift; sourceTree = ""; }; 812FA65365747604E58B98E7 /* MetalSceneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalSceneView.swift; sourceTree = ""; }; 8265144AA3E23B010A51E65A /* mac-test.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "mac-test.html"; sourceTree = ""; }; @@ -58,6 +60,7 @@ 59BD48D8076F347B7FF9A774 /* GlyphAtlas.swift */, 812FA65365747604E58B98E7 /* MetalSceneView.swift */, C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */, + 4E537A6D78CBFE2DD45EEA57 /* SdfFigures.swift */, ); path = Sources; sourceTree = ""; @@ -219,6 +222,7 @@ B93964796DBF64A171DD9439 /* GlyphAtlas.swift in Sources */, 7D438ADCB6BECAD5D0E6C8F7 /* MetalSceneView.swift in Sources */, C0C46FBB6805F1A1E055FED7 /* OskiewarApp.swift in Sources */, + B2694D04A86822B4F7712ABD /* SdfFigures.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apple/oskiewar/Sources/MetalSceneView.swift b/apple/oskiewar/Sources/MetalSceneView.swift index d8a33896ef..2cacdafe70 100644 --- a/apple/oskiewar/Sources/MetalSceneView.swift +++ b/apple/oskiewar/Sources/MetalSceneView.swift @@ -50,7 +50,13 @@ final class MetalSceneView: MTKView, MTKViewDelegate { var glyphs: MTLBuffer? var theme: MTLBuffer? var decals: MTLBuffer? + var sdfFigures: MTLBuffer? + var sdfPrims: MTLBuffer? + var bubbles: MTLBuffer? } + /// Raymarched figures; nil when the device refused the pipeline, and the + /// piece then draws triangles (sdfFigure returns false). + private let sdf: SdfFigures? private let uploads = (0..<3).map { _ in Uploads() } private let inFlight = DispatchSemaphore(value: 3) private var uploadIndex = 0 @@ -136,6 +142,7 @@ final class MetalSceneView: MTKView, MTKViewDelegate { } catch { fatalError("Metal scene pipeline failed: \(error)") } + sdf = try? SdfFigures(device: device) glyphAtlas = GlyphAtlas(device: device) do { glyphPipeline = try GlyphAtlas.pipeline(device: device) @@ -175,6 +182,7 @@ final class MetalSceneView: MTKView, MTKViewDelegate { sceneVertices.removeAll(keepingCapacity: true) poolDecalVertices.removeAll(keepingCapacity: true) glyphVertices.removeAll(keepingCapacity: true) + sdf?.clear() for key in themeSolid.keys { themeSolid[key]?.removeAll(keepingCapacity: true) } for key in themeSoft.keys { themeSoft[key]?.removeAll(keepingCapacity: true) } glyphAtlas.beginFrame() @@ -259,6 +267,20 @@ final class MetalSceneView: MTKView, MTKViewDelegate { return appended } + /// sceneApi 3: queue one raymarched figure for this frame. + func sdfFigure(camera: UnsafePointer, prims: UnsafePointer, count: Int) -> Bool { + sceneLock.lock() + defer { sceneLock.unlock() } + return sdf?.queue(camera: camera, values: prims, count: count) ?? false + } + + func sdfBubble(camera: UnsafePointer, x: Float, y: Float, z: Float, + radius: Float, tint: SIMD3) -> Bool { + sceneLock.lock() + defer { sceneLock.unlock() } + return sdf?.queueBubble(camera: camera, x: x, y: y, z: z, radius: radius, tint: tint) ?? false + } + func setPoolDecals(_ values: UnsafePointer, vertexCount: Int) { sceneLock.lock() defer { sceneLock.unlock() } @@ -295,6 +317,11 @@ final class MetalSceneView: MTKView, MTKViewDelegate { let themeSoftSnapshot = themeSoft let clear = background let stage = logicalSize + let sdfFigures = sdf?.figures ?? [] + let sdfPrims = sdf?.prims ?? [] + let sdfViews = sdf?.views ?? [] + let bubbles = sdf?.bubbles ?? [] + let bubbleViews = sdf?.bubbleViews ?? [] sceneLock.unlock() guard let pass = currentRenderPassDescriptor, let drawable = currentDrawable, @@ -309,6 +336,9 @@ final class MetalSceneView: MTKView, MTKViewDelegate { reserve(&slot.glyphs, bytes: glyphs.count * MemoryLayout.stride), reserve(&slot.theme, bytes: themeCount * MemoryLayout.stride), reserve(&slot.decals, bytes: poolDecals.count * MemoryLayout.stride), + reserve(&slot.sdfFigures, bytes: sdfFigures.count * MemoryLayout.stride), + reserve(&slot.sdfPrims, bytes: sdfPrims.count * MemoryLayout.stride), + reserve(&slot.bubbles, bytes: bubbles.count * MemoryLayout.stride), let vertexBuffer = slot.scene, let glyphBuffer = slot.glyphs, let themeBuffer = slot.theme, let decalBuffer = slot.decals else { return } let decalTexture = poolDecals.isEmpty ? nil : poolDecalTexture?(uploadIndex) @@ -327,6 +357,24 @@ final class MetalSceneView: MTKView, MTKViewDelegate { encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertices.count) } + if let sdf, !sdfFigures.isEmpty, let figureBuffer = slot.sdfFigures, + let primBuffer = slot.sdfPrims { + _ = sdfFigures.withUnsafeBytes { memcpy(figureBuffer.contents(), $0.baseAddress!, $0.count) } + _ = sdfPrims.withUnsafeBytes { memcpy(primBuffer.contents(), $0.baseAddress!, $0.count) } + encoder.setRenderPipelineState(sdf.pipeline) + encoder.setDepthStencilState(depthState) + encoder.setVertexBuffer(figureBuffer, offset: 0, index: 0) + encoder.setFragmentBuffer(figureBuffer, offset: 0, index: 0) + encoder.setFragmentBuffer(primBuffer, offset: 0, index: 1) + for run in sdfViews where run.count > 0 { + var constants = SdfFigures.constants(camera: run.camera, first: run.first, + drawable: drawableSize, stage: stage) + encoder.setVertexBytes(&constants, length: MemoryLayout.stride, index: 2) + encoder.setFragmentBytes(&constants, length: MemoryLayout.stride, index: 2) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, + instanceCount: run.count) + } + } if let decalTexture, !poolDecals.isEmpty { _ = poolDecals.withUnsafeBytes { memcpy(decalBuffer.contents(), $0.baseAddress!, $0.count) } encoder.setRenderPipelineState(poolDecalPipeline) @@ -336,6 +384,22 @@ final class MetalSceneView: MTKView, MTKViewDelegate { encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: poolDecals.count) } drawTheme(encoder, buffer: themeBuffer, solid: themeSolidSnapshot, soft: themeSoftSnapshot) + // Bubbles blend over everything solid in the world, before the HUD. + if let sdf, !bubbles.isEmpty, let bubbleBuffer = slot.bubbles { + _ = bubbles.withUnsafeBytes { memcpy(bubbleBuffer.contents(), $0.baseAddress!, $0.count) } + encoder.setRenderPipelineState(sdf.bubblePipeline) + encoder.setDepthStencilState(poolDecalDepth) + encoder.setVertexBuffer(bubbleBuffer, offset: 0, index: 0) + encoder.setFragmentBuffer(bubbleBuffer, offset: 0, index: 0) + for run in bubbleViews where run.count > 0 { + var constants = SdfFigures.constants(camera: run.camera, first: run.first, + drawable: drawableSize, stage: stage) + encoder.setVertexBytes(&constants, length: MemoryLayout.stride, index: 2) + encoder.setFragmentBytes(&constants, length: MemoryLayout.stride, index: 2) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, + instanceCount: run.count) + } + } // Text last and depth-free: the HUD is not in the world, it is over it. if !glyphs.isEmpty { let capacity = glyphBuffer.length / MemoryLayout.stride diff --git a/apple/oskiewar/Sources/SdfFigures.swift b/apple/oskiewar/Sources/SdfFigures.swift new file mode 100644 index 0000000000..67fdb92d55 --- /dev/null +++ b/apple/oskiewar/Sources/SdfFigures.swift @@ -0,0 +1,280 @@ +import MetalKit + +/// Raymarched figures (sceneApi 3), the Metal twin of +/// xbox/native-bios/SdfPixelShader.hlsl. The piece sends each figure as a list +/// of world-space round cones plus the game's own 27-float native camera; each +/// figure draws as one screen rectangle whose pixels march only that figure +/// and write true depth beside the triangle world. +struct SdfViewConstants { + var camPos = SIMD4(), right = SIMD4(), up = SIMD4() + var forward = SIMD4(), proj = SIMD4(), depthMap = SIMD4() + var viewport = SIMD4(), target = SIMD4(), stage = SIMD4() +} + +struct SdfFigureGpu { + var sphere: SIMD4 + var range: SIMD4 +} + +final class SdfFigures { + static let maxFigures = 256, maxPrims = 8_192 + static let outlinePx: Float = 1.6, blend: Float = 6 + + let pipeline: MTLRenderPipelineState + /// Bubbles blend over the scene: analytic spheres with a fresnel rim and + /// thin-film colour, depth-tested but never written. + let bubblePipeline: MTLRenderPipelineState + private(set) var bubbles: [SdfFigureGpu] = [] + private(set) var bubbleViews: [(camera: [Float], first: Int, count: Int)] = [] + private(set) var figures: [SdfFigureGpu] = [] + private(set) var prims: [Float] = [] + /// One entry per run of figures that share a camera: the P1 inset + /// interleaves a second view, and each run is one instanced draw. + private(set) var views: [(camera: [Float], first: Int, count: Int)] = [] + + init(device: MTLDevice) throws { + let library = try device.makeLibrary(source: Self.source, options: nil) + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "sdf_vertex") + descriptor.fragmentFunction = library.makeFunction(name: "sdf_fragment") + descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + descriptor.depthAttachmentPixelFormat = .depth32Float + pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + descriptor.fragmentFunction = library.makeFunction(name: "bubble_fragment") + let blend = descriptor.colorAttachments[0]! + blend.isBlendingEnabled = true + blend.sourceRGBBlendFactor = .one + blend.destinationRGBBlendFactor = .oneMinusSourceAlpha + blend.sourceAlphaBlendFactor = .one + blend.destinationAlphaBlendFactor = .oneMinusSourceAlpha + bubblePipeline = try device.makeRenderPipelineState(descriptor: descriptor) + } + + /// One bubble: world centre, radius and tint (0–255), in the given camera. + func queueBubble(camera: UnsafePointer, x: Float, y: Float, z: Float, + radius: Float, tint: SIMD3) -> Bool { + guard bubbles.count < 32, [x, y, z, radius].allSatisfy(\.isFinite), + radius > 0, radius < 4096 else { return false } + for i in 0..<27 where !camera[i].isFinite { return false } + let view = Array(UnsafeBufferPointer(start: camera, count: 27)) + if bubbleViews.last?.camera != view { bubbleViews.append((view, bubbles.count, 0)) } + bubbles.append(SdfFigureGpu(sphere: SIMD4(x, y, z, radius), + range: SIMD4(tint / 255, 0))) + bubbleViews[bubbleViews.count - 1].count += 1 + return true + } + + func clear() { + figures.removeAll(keepingCapacity: true) + prims.removeAll(keepingCapacity: true) + views.removeAll(keepingCapacity: true) + bubbles.removeAll(keepingCapacity: true) + bubbleViews.removeAll(keepingCapacity: true) + } + + /// camera: 27 floats. values: count × 12 floats (a.xyz r1, b.xyz r2, rgb, hard). + func queue(camera: UnsafePointer, values: UnsafePointer, count: Int) -> Bool { + guard count > 0, count <= 48, figures.count < Self.maxFigures, + prims.count / 12 + count <= Self.maxPrims else { return false } + for i in 0..<27 where !camera[i].isFinite { return false } + for i in 0.. 1e6 { return false } + let view = Array(UnsafeBufferPointer(start: camera, count: 27)) + if views.last?.camera != view { views.append((view, figures.count, 0)) } + var lo = SIMD3(repeating: .greatestFiniteMagnitude), hi = -lo + for i in 0.. SdfViewConstants { + SdfViewConstants( + camPos: SIMD4(m[0], m[1], m[2], 0), right: SIMD4(m[3], m[4], m[5], m[12]), + up: SIMD4(m[6], m[7], m[8], m[13]), forward: SIMD4(m[9], m[10], m[11], m[17]), + proj: SIMD4(m[14], m[15], m[16], Float(stage.width / max(1, drawable.width))), + depthMap: SIMD4(m[22], m[23], outlinePx, blend), viewport: SIMD4(m[18], m[19], m[20], m[21]), + target: SIMD4(Float(drawable.width), Float(drawable.height), Float(first), 0), + stage: SIMD4(Float(stage.width), Float(stage.height), 0, 0)) + } + + static let source = """ + #include + using namespace metal; + struct SdfView { float4 camPos, right, up, forward, proj, depthMap, viewport, target, stage; }; + struct SdfFigureData { float4 sphere; float4 range; }; + struct SdfPrim { float4 a; float4 b; float4 c; }; + struct SdfRaster { float4 position [[position]]; uint figure [[flat]]; }; + struct SdfOut { float4 color [[color(0)]]; float depth [[depth(any)]]; }; + + static float3 toView(constant SdfView& v, float3 p) { + float3 d = p - v.camPos.xyz; + return float3(dot(d, v.right.xyz), dot(d, v.up.xyz), dot(d, v.forward.xyz)); + } + static float scaleAt(constant SdfView& v, float z) { + return v.proj.x + (v.proj.y / max(z, 1e-3) - v.proj.x) * v.proj.z; + } + vertex SdfRaster sdf_vertex(uint vid [[vertex_id]], uint iid [[instance_id]], + const device SdfFigureData* figures [[buffer(0)]], + constant SdfView& v [[buffer(2)]]) { + SdfRaster out; + uint f = uint(v.target.z) + iid; + out.figure = f; + float4 s = figures[f].sphere; + float3 c = toView(v, s.xyz); + float2 lo = v.viewport.xy, hi = v.viewport.zw; + if (c.z - s.w > v.forward.w) { + float kc = scaleAt(v, c.z), kn = scaleAt(v, c.z - s.w); + float2 center = float2(v.right.w + c.x * kc, v.up.w - c.y * kc); + float2 extent = abs(c.xy) * abs(kn - kc) + s.w * kn + v.depthMap.z + 4.0; + lo = max(lo, center - extent); hi = min(hi, center + extent); + } + const uint corner[6] = {0, 1, 2, 2, 1, 3}; + uint k = corner[vid % 6]; + float2 p = float2((k & 1) ? hi.x : lo.x, (k & 2) ? hi.y : lo.y); + out.position = float4(p.x / (v.stage.x * 0.5) - 1.0, 1.0 - p.y / (v.stage.y * 0.5), 0.0, 1.0); + return out; + } + static float roundCone(float3 p, float3 a, float3 b, float r1, float r2) { + float3 ba = b - a; float l2 = dot(ba, ba); + if (l2 < 1e-4) return length(p - a) - max(r1, r2); + float rr = r1 - r2, a2 = l2 - rr * rr, il2 = 1.0 / l2; + float3 pa = p - a; float y = dot(pa, ba), z = y - l2; + float3 q = pa * l2 - ba * y; + float x2 = dot(q, q), y2 = y * y * l2, z2 = z * z * l2; + float k = sign(rr) * rr * rr * x2; + if (sign(z) * a2 * z2 > k) return sqrt(x2 + z2) * il2 - r2; + if (sign(y) * a2 * y2 < k) return sqrt(x2 + y2) * il2 - r1; + return (sqrt(x2 * a2 * il2) + y * rr) * il2 - r1; + } + static float smoothMin(float a, float b, float k) { + if (k <= 0.0) return min(a, b); + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; + } + static float figureDistance(float3 p, uint f, const device SdfFigureData* figures, + const device SdfPrim* prims, float k, thread float3& ink) { + uint first = uint(figures[f].range.x), count = uint(figures[f].range.y); + float d = 1e9, best = 1e9; ink = float3(1.0); + for (uint i = 0; i < count; ++i) { + SdfPrim prim = prims[first + i]; + float di = roundCone(p, prim.a.xyz, prim.b.xyz, prim.a.w, prim.b.w); + if (di < best) { best = di; ink = prim.c.rgb; } + d = prim.c.w > 0.5 ? min(d, di) : smoothMin(d, di, k); + } + return d; + } + // The bubble: one analytic sphere per instance, range.xyz its tint. + fragment SdfOut bubble_fragment(SdfRaster in [[stage_in]], + const device SdfFigureData* bubbles [[buffer(0)]], + constant SdfView& v [[buffer(2)]]) { + float4 sphere = bubbles[in.figure].sphere; + float3 tint = bubbles[in.figure].range.xyz; + float2 logical = in.position.xy * (v.stage.xy / v.target.xy); + float sx = logical.x - v.right.w, sy = v.up.w - logical.y; + float zc = max(toView(v, sphere.xyz).z, v.forward.w); + float A = v.proj.x * (1.0 - v.proj.z), B = v.proj.y * v.proj.z; + float denom = A * zc + B, kz = denom / zc; + float3 p0 = float3(sx / kz, sy / kz, zc); + float3 dv = float3(sx * B / (denom * denom), sy * B / (denom * denom), 1.0); + float3 ro = v.camPos.xyz + v.right.xyz * p0.x + v.up.xyz * p0.y + v.forward.xyz * p0.z; + float3 rd = normalize(v.right.xyz * dv.x + v.up.xyz * dv.y + v.forward.xyz * dv.z); + float3 oc = ro - sphere.xyz; + float b = dot(oc, rd), h = b * b - (dot(oc, oc) - sphere.w * sphere.w); + if (h < 0.0) discard_fragment(); + float t = -b - sqrt(h); + float3 p = ro + rd * t, n = normalize(p - sphere.xyz); + float facing = abs(dot(n, rd)); + float fresnel = pow(1.0 - facing, 2.2); + // Thin film: the rim runs through the spectrum as its thickness changes. + float film = fresnel * 1.6 + dot(n, float3(0.3, 0.6, 0.2)); + float3 iridescent = 0.5 + 0.5 * cos(6.2831 * (film + float3(0.0, 0.33, 0.67))); + float3 light = normalize(float3(0.35, -0.8, -0.45)); + float glint = pow(max(dot(reflect(rd, n), light), 0.0), 48.0); + float3 color = mix(tint, iridescent, 0.55) * (0.35 + fresnel) + glint; + float alpha = clamp(0.1 + fresnel * 0.85 + glint, 0.0, 1.0); + float viewZ = dot(p - v.camPos.xyz, v.forward.xyz); + if (viewZ < v.forward.w) discard_fragment(); + SdfOut out; + out.color = float4(color * alpha, alpha); + float depth = clamp(v.depthMap.x + viewZ * v.depthMap.y, -1.499, 1.4); + out.depth = saturate((depth + 1.5) / 3.0); + return out; + } + fragment SdfOut sdf_fragment(SdfRaster in [[stage_in]], + const device SdfFigureData* figures [[buffer(0)]], + const device SdfPrim* prims [[buffer(1)]], + constant SdfView& v [[buffer(2)]]) { + uint f = in.figure; + float4 sphere = figures[f].sphere; + float k = v.depthMap.w; + float2 logical = in.position.xy * (v.stage.xy / v.target.xy); + float sx = logical.x - v.right.w, sy = v.up.w - logical.y; + float zc = max(toView(v, sphere.xyz).z, v.forward.w); + float A = v.proj.x * (1.0 - v.proj.z), B = v.proj.y * v.proj.z; + float denom = A * zc + B, kz = denom / zc; + float3 p0 = float3(sx / kz, sy / kz, zc); + float3 dv = float3(sx * B / (denom * denom), sy * B / (denom * denom), 1.0); + float3 ro = v.camPos.xyz + v.right.xyz * p0.x + v.up.xyz * p0.y + v.forward.xyz * p0.z; + float3 rd = normalize(v.right.xyz * dv.x + v.up.xyz * dv.y + v.forward.xyz * dv.z); + float pixel = v.proj.w / kz; + float3 oc = ro - sphere.xyz; + float b = dot(oc, rd), h = b * b - (dot(oc, oc) - sphere.w * sphere.w); + if (h < 0.0) discard_fragment(); + float q = sqrt(h), t = -b - q, tEnd = -b + q; + float minD = 1e9, minT = t, hitT = 0; bool hit = false; + float3 ink; + for (int i = 0; i < 64 && t < tEnd; ++i) { + float d = figureDistance(ro + rd * t, f, figures, prims, k, ink); + if (d < minD) { minD = d; minT = t; } + if (d < pixel * 0.25) { hit = true; hitT = t; break; } + t += d; + } + bool outline = false; + if (!hit) { + if (minD >= v.depthMap.z * pixel) discard_fragment(); + outline = true; hitT = minT; + } + float3 p = ro + rd * hitT; + float viewZ = dot(p - v.camPos.xyz, v.forward.xyz); + if (viewZ < v.forward.w) discard_fragment(); + float3 color = float3(20.0, 17.0, 28.0) / 255.0; + if (!outline) { + float3 base; figureDistance(p, f, figures, prims, k, base); + float e = max(pixel * 0.5, 0.25); + float3 unused; + float3 n = normalize( + float3(1, -1, -1) * figureDistance(p + float3(e, -e, -e), f, figures, prims, k, unused) + + float3(-1, -1, 1) * figureDistance(p + float3(-e, -e, e), f, figures, prims, k, unused) + + float3(-1, 1, -1) * figureDistance(p + float3(-e, e, -e), f, figures, prims, k, unused) + + float3(1, 1, 1) * figureDistance(p + float3(e, e, e), f, figures, prims, k, unused)); + float light = n.x * 0.35 - n.y * 0.8 - n.z * 0.45; + float band = light > 0.35 ? 1.0 : light > -0.25 ? 0.8 : 0.55; + float occlusion = 0.0; + for (int j = 1; j <= 3; ++j) { + float hh = float(j) * 5.0; + occlusion += (hh - figureDistance(p + n * hh, f, figures, prims, k, unused)) / hh; + } + occlusion = clamp(1.0 - occlusion * 0.18, 0.6, 1.0); + float rim = pow(1.0 - saturate(dot(n, -rd)), 3.0); + color = base * band * occlusion + rim * 0.22; + } + SdfOut out; + out.color = float4(color, 1.0); + float depth = clamp(v.depthMap.x + viewZ * v.depthMap.y, -1.499, 1.4); + out.depth = saturate((depth + 1.5) / 3.0); + return out; + } + """ +} diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 0e3e77d967..978114cef5 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -305,9 +305,11 @@ ["KeyW", [0, "ArrowUp"]], ["KeyS", [0, "ArrowDown"]], ["BracketLeft", [0, "LeftShoulder"]], ["BracketRight", [0, "RightShoulder"]], ["KeyA", [0, "ArrowLeft"]], ["KeyD", [0, "ArrowRight"]], - ["Space", [0, "A"]], ["Enter", [0, "B"]], - ["ShiftLeft", [0, "X"]], ["ShiftRight", [0, "X"]], - ["AltLeft", [0, "Y"]], ["AltRight", [0, "Y"]], + // The pad's layout on keys: Q and E punch (X, Y), Shift kicks (A), + // Space jumps (B), Shift+Q is the bubble (A+X); Enter confirms (A). + ["KeyQ", [0, "X"]], ["KeyE", [0, "Y"]], + ["ShiftLeft", [0, "A"]], ["ShiftRight", [0, "A"]], + ["Space", [0, "B"]], ["Enter", [0, "A"]], ["KeyR", [0, "KeyQ"]], ["Escape", [0, "Menu"]], ["Backspace", [0, "Menu"]], ["Tab", [0, "View"]], // Pace keys: the piece steps its own clock on these and never reads @@ -317,9 +319,14 @@ ["Equal", [0, "SpeedUp"]], ["NumpadAdd", [0, "SpeedUp"]], ["Minus", [0, "SpeedDown"]], ["NumpadSubtract", [0, "SpeedDown"]], ["Period", [0, "SpeedUp"]], ["Comma", [0, "SpeedDown"]], - ["ArrowUp", [1, "ArrowUp"]], ["ArrowDown", [1, "ArrowDown"]], - ["ArrowLeft", [1, "ArrowLeft"]], ["ArrowRight", [1, "ArrowRight"]], - ["KeyK", [1, "A"]], ["KeyL", [1, "B"]], ["Semicolon", [1, "X"]], + // Arrows are WASD: one keyboard player steers with either hand. + ["ArrowUp", [0, "ArrowUp"]], ["ArrowDown", [0, "ArrowDown"]], + ["ArrowLeft", [0, "ArrowLeft"]], ["ArrowRight", [0, "ArrowRight"]], + + // Player two's cluster: IJKL move, U O ; ' are A B X Y. + ["KeyI", [1, "ArrowUp"]], ["KeyK", [1, "ArrowDown"]], + ["KeyJ", [1, "ArrowLeft"]], ["KeyL", [1, "ArrowRight"]], + ["KeyU", [1, "A"]], ["KeyO", [1, "B"]], ["Semicolon", [1, "X"]], ["Quote", [1, "Y"]], ]); globalThis.__oskiewarToggleDebug = () => { diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 56b1cb0668..d9391dbeff 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -2878,6 +2878,7 @@ const nativeRetainedMeshes = nativeMeshUpload !== null && nativeMeshDraw !== nul // `sdf` flag turns it off; a host without the pass returns false and the // tessellated figure draws instead. const nativeSdfFigure = typeof sdfFigure === "function" ? sdfFigure : null; +const nativeSdfBubble = typeof sdfBubble === "function" ? sdfBubble : null; const nativeTriangles3d = typeof triangles3d === "function" ? triangles3d : null; const nativePostEffects = typeof postEffects === "function" ? postEffects : null; const hostComicWrite = typeof comicWrite === "function" ? comicWrite : null; @@ -4260,12 +4261,6 @@ function drawAeselConnect(){ const saved=triangleDepth;triangleDepth=-1.49; const qr=shellMode==='MENU'?spectatorQrBox():null; const x=safe.right-handleWidth(label,size),y=qr?qr.top+qr.size+20:safe.top+18; - const lit=runtime().monotonicUs-aeselSeenAt<3500000,ink=lit?[163,244,211]:[194,196,220]; - const flutter=.35+.65*Math.abs(Math.sin(runtime().monotonicUs/100000)),bob=Math.sin(runtime().monotonicUs/380000)*2; - const cx=x-30,cy=y+22+bob,span=8+13*flutter; - filledDisc(cx,cy-12,4,ink);filledCapsule(cx,cy-5,cx,cy+10,4,ink); - screenTriangle(cx,cy,cx-span,cy-12,cx-span*.8,cy+10,...ink); - screenTriangle(cx,cy,cx+span,cy-12,cx+span*.8,cy+10,...ink); typeWrite(label,x+1,y+2,size,12,18,30);typeWrite(label,x,y,size,231,247,241); triangleDepth=saved; } @@ -5828,8 +5823,42 @@ function samplePad(index) { nativeControllerCount = controllers().length; if (index < nativeControllerCount) pad.localController = true; } + // One pad layout everywhere (@jeffrey, 2026-09-27): A kick (the gas on a + // board or kart), X punch, Y the other hand, B jump, A+X together the + // bubble. Only live play is translated — menus keep A confirm / B back — + // and bots and the harness speak the internal names directly. + if (pad && Array.isArray(pad.down) && shellMode === "GAME" && + globalThis.__oskiewarLegacyButtons !== true) + pad.down = translateButtons(index, pad.down); return pad; } +// Per-pad chord state. A and X wait two samples for each other so a bubble +// never starts with a stray kick or punch; a tap shorter than the wait still +// arrives, as a four-sample press. +const buttonChords = []; +function translateButtons(index, down) { + const s = buttonChords[index] ||= { a: 0, x: 0, holdA: 0, holdX: 0, latched: false }; + const A = down.includes("A"), X = down.includes("X"); + if (!A && s.a > 0 && s.a < 3 && !s.latched) s.holdA = 4; + if (!X && s.x > 0 && s.x < 3 && !s.latched) s.holdX = 4; + s.a = A ? s.a + 1 : 0; s.x = X ? s.x + 1 : 0; + if (A && X) s.latched = true; + else if (!A && !X) s.latched = false; + const p = players[index] || players[0], park = poolOnly() || depthControls(); + const vehicle = p.skateboard || p.goKart || p.onewheel, gun = p.gunAmmo > 0; + const out = down.filter((b) => b !== "A" && b !== "B" && b !== "X" && b !== "Y"); + const add = (b) => { if (!out.includes(b)) out.push(b); }; + const kickHeld = !s.latched && (A && s.a >= 3 || s.holdA > 0); + const punchHeld = !s.latched && (X && s.x >= 3 || s.holdX > 0); + if (s.holdA > 0) s.holdA--; + if (s.holdX > 0) s.holdX--; + if (s.latched && A && X) add(park ? "RightShoulder" : "X"); + if (kickHeld) add(park ? (vehicle ? "ArrowUp" : "Y") : "A"); + if (punchHeld) add(park && gun ? "Y" : "B"); + if (down.includes("Y")) add(gun || (!park && heldItem(p)) ? "Y" : "B"); + if (down.includes("B")) add(park ? "A" : "ArrowUp"); + return out; +} const localControllerPair = () => padSnapshots.every((pad) => pad?.localController === true); let localControllerPairSeen = false; @@ -6090,6 +6119,11 @@ const mph = (unitsPerSecond) => unitsPerSecond / unitsPerMetre * 2.23694; function statusVerb(rider, speed, includeSpin=true) { const mphText = Math.round(mph(speed)) + " mph"; if (wallPressActive(rider)) return [rider.ducking ? "crouching against wall" : "pressing into wall", ""]; + // Actions read before movement: a punch thrown while walking is a punch. + if (rider.blocking) return ["blocking", ""]; + if (/punch/.test(rider.attackKind || "")) return ["punching", ""]; + if (/kick/.test(rider.attackKind || "")) return ["kicking", ""]; + if (rider.attackKind) return [String(rider.attackKind).replace(/_/g, " ").toLowerCase(), ""]; if (includeSpin && rider.spin) return ["spinning", Math.round(spinRpm(rider)) + " rpm"]; if (rider.swimming) return ["swimming", mphText]; if (rider.skateStall) return ["holding", ""]; @@ -17588,18 +17622,18 @@ function controlLocale() { }; return keyboard ? { title: "start", - select: "P1 A/D + SPACE P2 LEFT/RIGHT + K H P2/DUMMY/BOT G BACK", + select: "P1 A/D + SPACE P2 J/L + U H P2/DUMMY/BOT G BACK", replayPaused: "PAUSED F PLAY A D SCRUB G EXIT", replayPlaying: "F PAUSE A D SCRUB G EXIT", replay: "Q REPLAY", - combat: "SPACE KICK ENTER PUNCH SHIFT BUBBLE ALT USE ITEM W AIR", + combat: "Q E PUNCH SHIFT KICK SPACE JUMP SHIFT+Q BUBBLE", } : { title: "start", select: "LEFT RIGHT SELECT A READY X P2 / DUMMY / BOT B BACK", replayPaused: "PAUSED A PLAY LEFT RIGHT SCRUB B EXIT", replayPlaying: "A PAUSE LEFT RIGHT SCRUB B EXIT", replay: "Y REPLAY", - combat: "A KICK B PUNCH X BUBBLE Y USE ITEM UP AIR", + combat: "X PUNCH Y PUNCH A KICK B JUMP A+X BUBBLE", }; } @@ -17607,27 +17641,28 @@ function controlLocale() { // so the legend can show a key rather than spell one. function combatKeys() { const caps = typeof capabilities === "function" ? capabilities() : {}; + // Caps name the physical key; the last field is the internal button that + // lights it once translateButtons has mapped the pad. if (depthControls()) return caps.inputFamily === "keyboard" ? [ [["W", "A", "S", "D"], "MOVE 3D", "ArrowUp"], - ["SPACE", "JUMP", "A"], ["SHIFT", "CROUCH", "X"], - ["ENTER", "PUNCH", "B"], ["ALT", "KICK", "Y"]] : [ + [["Q", "E"], "PUNCH", "B"], ["SHIFT", "KICK", "Y"], + ["SPACE", "JUMP", "A"], [["SHIFT", "Q"], "BUBBLE", "RightShoulder"]] : [ [["LEFT", "RIGHT"], "MOVE", "ArrowLeft"], [["UP", "DOWN"], "DEPTH", "ArrowUp"], - ["A", "JUMP", "A"], ["X", "CROUCH", "X"], - ["B", "PUNCH", "B"], ["Y", "KICK", "Y"], ["RB", "BUBBLE", "RightShoulder"]]; + ["X", "PUNCH", "B"], ["Y", "PUNCH", "B"], ["A", "KICK", "Y"], + ["B", "JUMP", "A"], [["A", "X"], "BUBBLE", "RightShoulder"]]; if (caps.inputFamily === "keyboard") return [ [["A", "D"], "MOVE", "ArrowLeft"], [["D", "D"], ">> DASH", "ArrowRight"], - ["SPACE", "KICK", "A"], ["ENTER", "PUNCH", "B"], - ["SHIFT", "BUBBLE", "X"], ["ALT", "USE ITEM", "Y"], - ["W", "AIR", "ArrowUp"]]; + [["Q", "E"], "PUNCH", "B"], ["SHIFT", "KICK", "A"], + ["SPACE", "JUMP", "ArrowUp"], [["SHIFT", "Q"], "BUBBLE", "X"]]; if (caps.inputFamily === "touch") return [ ["A", "KICK", "A"], ["B", "PUNCH", "B"], ["X", "BUBBLE", "X"], ["Y", "USE ITEM", "Y"]]; return [ [["LEFT", "RIGHT"], "MOVE", "ArrowLeft"], [["RIGHT", "RIGHT"], ">> DASH", "ArrowRight"], - ["A", "KICK", "A"], ["B", "PUNCH", "B"], ["X", "BUBBLE", "X"], - ["Y", "USE ITEM", "Y"], ["STICK_UP", "AIR", "ArrowUp"]]; + ["X", "PUNCH", "B"], ["Y", "PUNCH", "B"], ["A", "KICK", "A"], + ["B", "JUMP", "ArrowUp"], [["A", "X"], "BUBBLE", "X"]]; } // What the buttons do, named on the way into a round. B changes meaning with @@ -19172,7 +19207,10 @@ function drawLooseRunner(player,world,t,lod){ const key=bone.part||bone.role;if(!groups.has(key))groups.set(key,[]);groups.get(key).push(bone); } const ordered=[...groups.values()].map(bones=>({bones,depth:bones.reduce((sum,b)=>sum+b.depth,0)/bones.length})).sort((a,b)=>b.depth-a.depth); - for(const {bones,depth} of ordered){ + // With a raymarching host the body is one SDF figure and these flat + // strokes are skipped; the drawn head, face and cloth still go on top. + const sdfBody=nativeSdfFigure&&renderFlags.sdf!==false&&drawSdfRunner(player,world,t,true); + if(!sdfBody)for(const {bones,depth} of ordered){ const limb=/arm|leg/.test(bones[0].part||''); if(!limb){ triangleDepth=depth; @@ -19241,7 +19279,9 @@ const sdfPrims=new Float32Array(48*12); // The same body drawSpatialRunner tessellates, as round cones: limbs taper, // the torso and skirt are cones, the head is a skin sphere with a hair cap // set back from the face, and the eyes cut in with a hard union. -function drawSdfRunner(player,world,t){ +// loose: the body only. The loose cartoon keeps its drawn head, face, hair, +// skirt and cloth over it, and draws the vehicle and items itself. +function drawSdfRunner(player,world,t,loose=false){ const scale=player.civilian?(player.bodyScale||1):1; const yaw=(player.poolYaw||0)+(player.spin?.angle||0); const forward={x:Math.cos(yaw),y:0,z:Math.sin(yaw)},side={x:-forward.z,y:0,z:forward.x}; @@ -19273,13 +19313,16 @@ function drawSdfRunner(player,world,t){ const hip=end(torso,2),neck=end(torso,1),ink=dressed?shirt:skin; const mid={x:lerp(hip.x,neck.x,.62),y:lerp(hip.y,neck.y,.62),z:lerp(hip.z,neck.z,.62)}; put(hip,mid,14*scale,17*scale,ink);put(mid,neck,17*scale,10*scale,ink); - if(dressed&&!player.pantsColor){ + if(dressed&&!player.pantsColor&&!loose){ const sway=Math.sin((player.poolStridePhase||0)*Math.PI*2)*3+(player.rig?.lean||0)*.16; const top={...hip,y:hip.y-3*scale}; put(top,point(top,sway*scale,-30*scale,0),12*scale,19*scale,player.skirtColor||[188,164,226],1); } } - if(!player.headless){ + if(loose){ + const pony=ponytailStates.get(player),hair=player.hairColor||[35,27,34],r=world.head.radius; + if(pony)for(let i=1;i0){ + if(player.gunAmmo>0&&!loose){ const pose=gunPose(player,runtime().monotonicUs); put(pose.hand,pose.muzzle,4*scale,4*scale,[45,48,55],1); put(pose.hand,{...pose.hand,y:pose.hand.y+14*scale},4*scale,3*scale,[30,29,34],1); @@ -19303,6 +19346,7 @@ function drawSdfRunner(player,world,t){ let drawn=false; try{drawn=!!count&&nativeSdfFigure(sdfPrims,count,mainNativeCamera());}catch{drawn=false;} if(!drawn)return false; + if(loose)return true; if(player.goKart)drawGoKart({...player.goKart,x:player.x,y:player.y,z:player.z,poolYaw:player.poolYaw,skatePitch:player.skatePitch});else if(player.skateboard)drawSkateboard(player); const saved=triangleDepth; triangleDepth=projectPoint(world.head.x,world.head.y,world.head.z).z-.004; @@ -19463,7 +19507,14 @@ function drawSparringTarget(player,world,t){ typeWrite(label,head.x-handleWidth(label,size)/2,head.y-r-size-8,size,...ink);triangleDepth=old; } function drawPlayerBubble(player,t){ - const world=shieldGeometry(player),point=projectPoint(world.x,world.y,world.z); + const world=shieldGeometry(player); + // A raymarching host shades the bubble as a real sphere: fresnel rim, + // thin-film colour and a glint, blended over the world. + if(nativeSdfBubble&&renderFlags.sdf!==false){ + const tint=player.accent||[150,220,255]; + try{if(nativeSdfBubble(world.x,world.y,world.z,world.radius,tint[0],tint[1],tint[2],mainNativeCamera()))return;}catch{} + } + const point=projectPoint(world.x,world.y,world.z); if(point.behind)return; const old=triangleDepth; triangleDepth=point.z-world.radius*cameraDoll.depthSlope-.0001; @@ -24227,27 +24278,28 @@ function seatHudReadout(p,now){ seatHudReadouts.set(p,readout); return readout; } +// The park hints are keycaps on a keyboard and button discs on a pad (the +// shared drawKeycapRun), one short verb each. The third field is the internal +// button translateButtons produces, which lights the cap while held. +function parkControlCaps(){ + return keycapFamily()?{punch:['Q','E'],kick:'SHIFT',jump:'SPACE',bubble:['SHIFT','Q'],drop:'R'} + :{punch:['X','Y'],kick:'A',jump:'B',bubble:['A','X'],drop:'L3'}; +} function drawParkControls(p,safe){ - const down=inputPads[p.pad]?.down||[],fighting=!!parkFightRival(p),size=42; - const active=keys=>keys.some(key=>down.includes(key))||keys.includes('ArrowUp')&&(inputPads[p.pad]?.leftY||0)>.48||keys.includes('ArrowDown')&&(inputPads[p.pad]?.leftY||0)<-.48; + const down=inputPads[p.pad]?.down||[],fighting=!!parkFightRival(p),size=28,c=parkControlCaps(); + const held=[...down]; + if((inputPads[p.pad]?.leftY||0)>.48)held.push('ArrowUp'); + const ink=[200,215,232]; const drop=parkDropName(p); - if(drop)seatHudText('L3 drop '+drop,safe.left,safe.bottom-size,size,active(['LeftStick','KeyQ'])?[255,245,130]:[255,186,126]); - const rows=[[ - {text:p.chalkColor&&!fighting?'B draw':p.axeHeld?'B swing':'B punch',keys:['B']}, - {text:p.gunAmmo>0?'Y shoot':'Y kick',keys:['Y']}], - p.chalkDrawing?[{text:'RB spin',keys:['RightShoulder']}]:[ - {text:fighting?'↑ jump':p.skateboard?'A dismount':'A jump',keys:fighting?['ArrowUp','A']:['A']}, - ...(fighting?[{text:'↓ duck',keys:['ArrowDown','X']}]:[]), - {text:'RB block',keys:['RightShoulder']}]]; - for(const [row,runs] of rows.entries()){ - const separator=' / ',gap=handleWidth(separator,size),width=runs.reduce((n,r)=>n+handleWidth(r.text,size),0)+gap*(runs.length-1); - let x=safe.right-width;const y=safe.bottom-92+row*50; - for(const [index,run] of runs.entries()){ - const lit=active(run.keys),w=handleWidth(run.text,size); - if(lit)hudBox(x-5,y-2,w+10,size+7,43,64,79); - seatHudText(run.text,x,y,size,lit?[255,246,139]:[200,215,232]);x+=w; - if(index0; + const rows=[ + [[c.punch,p.chalkColor&&!fighting?'draw':p.axeHeld?'swing':gun?'shoot':'punch',gun?'Y':'B'], + [c.kick,vehicle?'gas':'kick',vehicle?'ArrowUp':'Y']], + [[c.jump,p.skateboard&&!fighting?'off':'jump','A'],[c.bubble,'bubble','RightShoulder']]]; + for(const [row,entries] of rows.entries()){ + const y=safe.bottom-size*3.4+row*size*1.8; + drawKeycapRun(entries,safe.right-keycapRunWidth(entries,size),y,size,held,ink); } } function drawSeatPlayerHud(ink){ diff --git a/xbox/live/tests/oskiewar.test.mjs b/xbox/live/tests/oskiewar.test.mjs index ee1154d39d..44cb4e523c 100644 --- a/xbox/live/tests/oskiewar.test.mjs +++ b/xbox/live/tests/oskiewar.test.mjs @@ -163,6 +163,8 @@ function createFight(startImmediately = true, enterGame = true, // These tests place riders by hand; the doorway walk-in has its own tests // in pool-playground.test.mjs. globalThis.__oskiewarParkIntro = false; + // These tests drive the internal button names; translateButtons has its own. + globalThis.__oskiewarLegacyButtons = true; const opponentOverride = globalThis.__oskiewarOpponent; if (opponentOverride === undefined) globalThis.__oskiewarOpponent = "dummy"; fight.boot(); diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index c4098399c2..7f22b313eb 100644 --- a/xbox/live/tests/pool-playground.test.mjs +++ b/xbox/live/tests/pool-playground.test.mjs @@ -13,7 +13,7 @@ function playground(legacyAudio=false){ bullets,updateBullets,gunPose,drawPoolGeometry,captureQuadMesh,drawRunner, boundParkBody,parkWindowWalls,brokenParkWindows,parkWindowShards,resetParkWindows, breakParkWindow,updateParkWindowShards,insidePark,parkLotMargin,updateCameraDoll,clearPoolCamera,updateMotorAudio,updateSkateAudio,balls, - updateSeatHeartbeat,updatePonytail,ponytailAnchor,ponytailStates,drawSpatialRunner,sampleCombatBoxes,sweptProjectileContact,damageParkCivilian,drawAeselFairy,sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod,spectatorQrBox,returnToTitle,beginFreeskate,sessionName:()=>sessionName, + updateSeatHeartbeat,updatePonytail,ponytailAnchor,ponytailStates,drawSpatialRunner,sampleCombatBoxes,sweptProjectileContact,damageParkCivilian,drawAeselFairy,sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod,spectatorQrBox,returnToTitle,beginFreeskate,translateButtons,sessionName:()=>sessionName, state:()=>({halfpipe:parkHalfPipe3D})};`)(()=>({monotonicUs:now}),()=>({platform:'web'}),noop,(event,seat,value,value2)=>{assert.ok(seat>=-1&&seat<=3);assert.ok(Number.isFinite(value)&&Number.isFinite(value2));},(name,gain,pan)=>{if(legacyAudio&&!['kick','snare','hat','block'].includes(name))throw new RangeError('unknown drum');drums.push({name,gain,pan});},noop,noop,noop,noop,noop,noop,(hz,gain)=>audio.push({hz,gain}),()=>audio.push({stop:true})); const p=api.players[0];Object.assign(p,{x:1080,z:0,y:api.poolFloorAt(1080,0),grounded:true,alive:true,dummy:false,skateboard:false,poolYaw:0,previous:[],spin:null,directionChanges:[],poolLastSteer:0}); api.step=(down=[],dt=1/60)=>{now+=dt*1e6;api.updatePlayer(p,{down,leftX:0,leftY:0},dt,api.clock());}; @@ -775,3 +775,28 @@ test('the park join QR survives round resets and the title, and follows the room assert.ok(a.spectatorQrBox(),'QR shows again on the next run'); }finally{if(saved===undefined)delete globalThis.qrcode;else globalThis.qrcode=saved;} }); +test('one pad layout: A kick or gas, X punch, Y other hand, B jump, A+X bubble',()=>{ + const a=playground(),p=a.players[0]; + const run=(frames)=>frames.map(down=>a.translateButtons(0,down)); + // A alone waits two samples for X, then kicks (the park's internal kick is Y). + let out=run([['A'],['A'],['A']]); + assert.deepEqual(out[0],[]);assert.deepEqual(out[1],[]);assert.ok(out[2].includes('Y')); + run([[]]); + // A and X together are the bubble and nothing else. + out=run([['A','X'],['A','X'],['A','X'],['A','X']]); + for(const f of out){assert.ok(f.includes('RightShoulder'));assert.ok(!f.includes('Y')&&!f.includes('B'));} + // Letting go of one does not leak a kick or punch from the other. + out=run([['X'],['X'],['X'],[]]); + for(const f of out)assert.ok(!f.includes('B')); + // A tap shorter than the wait still lands, held long enough to read. + out=run([['X'],[],[],[],[],[]]); + assert.ok(out.slice(1,5).every(f=>f.includes('B')),'short X tap becomes a held punch'); + // B jumps, Y is the other hand's punch. + assert.ok(a.translateButtons(0,['B']).includes('A')); + assert.ok(a.translateButtons(0,['Y']).includes('B')); + run([[],[],[],[],[]]); + // On a board, A is the gas. + p.skateboard=true; + out=run([['A'],['A'],['A']]); + assert.ok(out[2].includes('ArrowUp')); +}); diff --git a/xbox/macos-native/main.swift b/xbox/macos-native/main.swift index 4f04e32e53..2c49700a03 100644 --- a/xbox/macos-native/main.swift +++ b/xbox/macos-native/main.swift @@ -217,18 +217,27 @@ private final class NativeInput { case 1: return (0, "ArrowDown") // S case 0: return (0, "ArrowLeft") // A case 2: return (0, "ArrowRight") // D - case 49, 3: return (0, "A") // Space / F: kick - case 36, 5: return (0, "B") // Return / G: punch - case 56, 60, 11, 4: return (0, "X") // Shift / B / H: shield - case 58, 61: return (0, "Y") // Option: use item + // The pad's layout on keys: Q and E punch (X, Y), Shift kicks (A), + // Space jumps (B), Shift+Q is the bubble (A+X); Return confirms (A). + case 56, 60, 36: return (0, "A") // Shift / Return + case 49: return (0, "B") // Space + case 12: return (0, "X") // Q + case 14: return (0, "Y") // E + case 15: return (0, "KeyQ") // R: drop the held item case 53, 51: return (0, "Menu") // Escape / Backspace - case 48, 12: return (0, "View") // Tab / Q: debug - case 126: return (1, "ArrowUp") - case 125: return (1, "ArrowDown") - case 123: return (1, "ArrowLeft") - case 124: return (1, "ArrowRight") - case 40: return (1, "A") // K - case 37: return (1, "B") // L + case 48: return (0, "View") // Tab: debug + case 33: return (0, "LeftShoulder") // [ + case 30: return (0, "RightShoulder") // ]: block + case 126: return (0, "ArrowUp") // arrows are WASD + case 125: return (0, "ArrowDown") + case 123: return (0, "ArrowLeft") + case 124: return (0, "ArrowRight") + case 34: return (1, "ArrowUp") // player two: IJKL move + case 40: return (1, "ArrowDown") // K + case 38: return (1, "ArrowLeft") // J + case 37: return (1, "ArrowRight") // L + case 32: return (1, "A") // U + case 31: return (1, "B") // O case 41: return (1, "X") // ; case 39: return (1, "Y") // ' default: return nil @@ -896,6 +905,18 @@ private final class NativeGameHost { else { NSCursor.arrow.set() } } + /// A Float32Array's own storage, or nil when the value is anything else or + /// shorter than the caller needs. + private static func float32(_ value: JSValue, minimum: Int) -> UnsafePointer? { + guard let context = value.context?.jsGlobalContextRef else { return nil } + var exception: JSValueRef? + guard JSValueGetTypedArrayType(context, value.jsValueRef, &exception) == kJSTypedArrayTypeFloat32Array, + let object = JSValueToObject(context, value.jsValueRef, &exception), + JSObjectGetTypedArrayLength(context, object, &exception) >= minimum, + let bytes = JSObjectGetTypedArrayBytesPtr(context, object, &exception) else { return nil } + return UnsafePointer(bytes.assumingMemoryBound(to: Float.self)) + } + private func installBindings() { javascript.exceptionHandler = { [weak self] _, exception in self?.javascriptError = exception?.toString() ?? "javascript exception" @@ -1029,6 +1050,26 @@ private final class NativeGameHost { poolDecals.draw(handle: handle, camera: PoolDecals.floats(camera, limit: 27), bounds: [Float(x), Float(z), Float(width), Float(depth)]) } + // sceneApi 3: sdfFigure(prims Float32Array, count, camera Float32Array). + // Both arrays are read in place through JavaScriptCore's typed-array + // pointer, never element by element. + let sdfFigure: @convention(block) (JSValue, Int32, JSValue) -> Bool = { [weak self] prims, count, camera in + guard let self, count > 0, count <= 48, + let values = Self.float32(prims, minimum: Int(count) * 12), + let view = Self.float32(camera, minimum: 27) else { return false } + return self.view.scene.sdfFigure(camera: view, prims: values, count: Int(count)) + } + javascript.setObject(sdfFigure, forKeyedSubscript: "sdfFigure" as NSString) + // sdfBubble(x, y, z, radius, r, g, b, camera): a shaded shield bubble. + let sdfBubble: @convention(block) () -> Bool = { [weak self] in + let args = JSContext.currentArguments() as? [JSValue] ?? [] + guard let self, args.count >= 8, + let view = Self.float32(args[7], minimum: 27) else { return false } + let v = args.prefix(7).map { Float($0.toDouble()) } + return self.view.scene.sdfBubble(camera: view, x: v[0], y: v[1], z: v[2], + radius: v[3], tint: SIMD3(v[4], v[5], v[6])) + } + javascript.setObject(sdfBubble, forKeyedSubscript: "sdfBubble" as NSString) javascript.setObject(clearDecals, forKeyedSubscript: "decalClear" as NSString) javascript.setObject(stampDecal, forKeyedSubscript: "decalStamp" as NSString) javascript.setObject(stampDecal, forKeyedSubscript: "decalTint" as NSString) diff --git a/xbox/tools/build-macos-app.sh b/xbox/tools/build-macos-app.sh index 312a316e6d..18be53c7ac 100755 --- a/xbox/tools/build-macos-app.sh +++ b/xbox/tools/build-macos-app.sh @@ -20,6 +20,7 @@ clang++ -std=c++17 -O2 -c "$repo_root/xbox/macos-native/PoolDecals.cpp" -o "$obj swiftc -swift-version 5 -O -import-objc-header "$repo_root/xbox/macos-native/PoolDecals.h" \ "$repo_root/xbox/macos-native/PoolDecals.swift" "$object_dir/PoolDecals.o" -lc++ "$repo_root/xbox/macos-native/main.swift" \ "$repo_root/apple/oskiewar/Sources/MetalSceneView.swift" \ + "$repo_root/apple/oskiewar/Sources/SdfFigures.swift" \ "$repo_root/apple/oskiewar/Sources/GlyphAtlas.swift" \ -framework AppKit -framework AVFoundation -framework CoreVideo \ -framework GameController -framework JavaScriptCore \ -- 2.51.2