From 8758132b4622133b46866aa27e2c011bc11c8fe3 Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 14 Aug 2026 12:02:05 -0700 Subject: [PATCH 1/2] refit map when viewport size changes Co-authored-by: Cursor --- client/global_client.html | 5 ++++- client/global_client.nim | 24 +++++++++++++++++------- tests/test_client.nim | 10 ++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/client/global_client.html b/client/global_client.html index 41d62a4..e69c9ce 100644 --- a/client/global_client.html +++ b/client/global_client.html @@ -766,13 +766,16 @@ function setViewport(layerId,width,height){ const layer=ensureLayer(layerId); + const sizeChanged=layer.width!==width||layer.height!==height; + const wasMap=isMapLayer(layer); layer.width=width; layer.height=height; layer.canvas.width=width; layer.canvas.height=height; layer.mips=[]; layer.image=layer.ctx.createImageData(width,height); - maybeFit(); + if(wasMap&&sizeChanged)fit(); + else maybeFit(); } function putSpritePixel(layer,x,y,sprite,srcOffset){ diff --git a/client/global_client.nim b/client/global_client.nim index 17647b1..7e7bdd5 100644 --- a/client/global_client.nim +++ b/client/global_client.nim @@ -315,6 +315,22 @@ proc maybeFit*(app: GlobalApp) = if app.autoFit: app.fit() +proc applyLayerViewport(app: GlobalApp, layerId, width, height: int) = + ## Updates one layer size and refits when the map world size changes. + var layer = app.layerIndex(layerId) + let + sizeChanged = layer.width != width or layer.height != height + wasMap = layer.isMapLayer + layer.width = width + layer.height = height + layer.image = nil + layer.dirty = true + app.layers[layerId] = layer + if wasMap and sizeChanged: + app.fit() + else: + app.maybeFit() + proc zoomMapAt(app: GlobalApp, mouseLogical: IVec2, scrollY: float32) = ## Zooms the map at one screen coordinate, even when UI overlays are under it. let layer = app.mapLayer() @@ -727,13 +743,7 @@ proc parseMessage*(app: GlobalApp, data: string) = if width <= 0 or height <= 0: app.closeNetwork() return - var layer = app.layerIndex(layerId) - layer.width = width - layer.height = height - layer.image = nil - layer.dirty = true - app.layers[layerId] = layer - app.maybeFit() + app.applyLayerViewport(layerId, width, height) of 0x06: require(3) let diff --git a/tests/test_client.nim b/tests/test_client.nim index b360cab..e388af5 100644 --- a/tests/test_client.nim +++ b/tests/test_client.nim @@ -61,6 +61,15 @@ proc testGlobalClientWheelZoomTargetsMap() = doAssert "zoomMapAt(event.clientX,event.clientY,event.deltaY);" in html doAssert "addEventListener(\"wheel\",event=>{\n event.preventDefault();\n const point=mousePoint(event);" notin html +proc testGlobalClientRefitsOnMapViewportChange() = + ## Tests that a map viewport size change refits even after pan or zoom. + echo "Testing global client refits on map viewport change" + let html = readClientHtml(CoworldGlobalClientRoute) + doAssert "const sizeChanged=layer.width!==width||layer.height!==height;" in html + doAssert "const wasMap=isMapLayer(layer);" in html + doAssert "if(wasMap&&sizeChanged)fit();" in html + doAssert "else maybeFit();" in html + proc testPlayerClientSpeaksSpriteProtocol() = ## Tests the shared player client covers the sprite protocol used by bots. echo "Testing player client sprite protocol support" @@ -98,6 +107,7 @@ testClientStaticPaths() testReplayClientPreservesUri() testGlobalClientFullScreenLayers() testGlobalClientWheelZoomTargetsMap() +testGlobalClientRefitsOnMapViewportChange() testPlayerClientSpeaksSpriteProtocol() testEmbeddedClientBodies() echo "All tests passed" From 91ba0b1134e9724e66e4370497306889e8fccd9c Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 17 Aug 2026 10:02:39 -0700 Subject: [PATCH 2/2] Scale global client UI pixels to the window size The Nim/wasm global client drew UI layers at a fixed 3x, so a small embed (softmax.com's ~875x500 Heartleaf replay frame) clipped the bottom bar and overlapped the score panel. Pick the largest stepped zoom (0.5, 1, 2, 3, 4) at which every UI layer fits the window with 1.5x padding, like Polyworld's stepped HUD scale, and use the same steps in the HTML global client. Layer textures use linear minification so 0.5x UI and zoomed-out maps average pixels instead of dropping rows. Co-Authored-By: Claude Fable 5 --- client/global_client.html | 23 +++++++++++------------ client/global_client.nim | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/client/global_client.html b/client/global_client.html index e69c9ce..16fdf59 100644 --- a/client/global_client.html +++ b/client/global_client.html @@ -221,8 +221,8 @@ } const playerMode=url.searchParams.get("player")==="1"|| addressPath()==="/player"; -const ZoomableFlag=1,UiFlag=2,MapLayerType=0,FullScreenLayerType=9,MaxUiZoom=3; -const MaxUiZoomFitPadding=1.5; +const ZoomableFlag=1,UiFlag=2,MapLayerType=0,FullScreenLayerType=9; +const UiZoomSteps=[0.5,1,2,3,4],UiZoomFitPadding=1.5; const ButtonUp=1,ButtonDown=2,ButtonLeft=4,ButtonRight=8; const ButtonSelect=16,ButtonA=32,ButtonB=64; const DebugUpdateFlashMs=900; @@ -810,19 +810,18 @@ } function uiZoom(){ - let zoom=MaxUiZoom; + // Largest stepped zoom at which every UI layer still fits the window + // with padding: small windows shrink UI pixels, large windows grow them. + let zoom=UiZoomSteps[UiZoomSteps.length-1]; for(const layer of layers.values()){ - if((layer.flags&UiFlag)===0)continue; + if(!isUiLayer(layer)||isMapLayer(layer)||isFullScreenLayer(layer))continue; const fit=Math.min( - innerWidth/layer.width, - innerHeight/layer.height + innerWidth/Math.max(1,layer.width), + innerHeight/Math.max(1,layer.height) ); - let layerZoom=1; - for(let scale=MaxUiZoom;scale>=2;scale--){ - if(fit>=scale*MaxUiZoomFitPadding){ - layerZoom=scale; - break; - } + let layerZoom=UiZoomSteps[0]; + for(const step of UiZoomSteps){ + if(fit>=step*UiZoomFitPadding)layerZoom=step; } zoom=Math.min(zoom,layerZoom); } diff --git a/client/global_client.nim b/client/global_client.nim index 7e7bdd5..9119a8a 100644 --- a/client/global_client.nim +++ b/client/global_client.nim @@ -79,7 +79,10 @@ const UiFlag = SpriteLayerUiFlag MapLayerKind = SpriteLayerMap FullScreenLayerKind = SpriteLayerFullScreen - UiZoom = 3.0'f + UiZoomSteps = [0.5'f, 1.0'f, 2.0'f, 3.0'f, 4.0'f] + ## Crisp stepped UI zooms, smallest to largest, like Polyworld's HUD. + UiZoomFitPadding = 1.5'f + ## A UI layer may take at most 1/1.5 of the window at its zoom. when not defined(emscripten): const NetworkPollPasses = 8 when defined(emscripten): @@ -361,6 +364,26 @@ proc refreshDisplayScale(app: GlobalApp) = app.window.size = logicalSize.scaledWindowSize(scale) app.maybeFit() +proc uiZoom(app: GlobalApp, logicalW, logicalH: float32): float32 = + ## Returns the largest stepped UI zoom at which every UI layer still + ## fits inside the window with padding to spare. Small windows shrink + ## the UI pixels and large windows grow them, matching the HTML client. + result = UiZoomSteps[^1] + for layer in app.layers.values: + if not layer.isUiLayer or layer.isMapLayer or layer.isFullScreenLayer: + continue + if layer.width <= 0 or layer.height <= 0: + continue + let fit = min( + logicalW / layer.width.float32, + logicalH / layer.height.float32 + ) + var layerZoom = UiZoomSteps[0] + for step in UiZoomSteps: + if fit >= step * UiZoomFitPadding: + layerZoom = step + result = min(result, layerZoom) + proc layerScreenRect( app: GlobalApp, layer: GlobalLayer, @@ -394,8 +417,9 @@ proc layerScreenRect( ) let - w = layer.width.float32 * UiZoom - h = layer.height.float32 * UiZoom + zoom = app.uiZoom(logicalW, logicalH) + w = layer.width.float32 * zoom + h = layer.height.float32 * zoom case layer.kind of 1: (x: 0.0'f, y: 0.0'f, w: w, h: h) @@ -459,7 +483,9 @@ proc allocateLayerImage(app: GlobalApp, layer: var GlobalLayer) = if layer.textureId == 0: glGenTextures(1, layer.textureId.addr) glBindTexture(GL_TEXTURE_2D, layer.textureId) - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST.GLint) + # Nearest when magnified keeps pixels crisp; linear when minified so a + # zoomed-out map or 0.5x UI averages pixels instead of dropping rows. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR.GLint) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST.GLint) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE.GLint) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE.GLint)