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 \