From 828b1efc9ccfb243e3223037516a284f1675382d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 2 Aug 2026 23:34:16 +0300 Subject: [PATCH] sync terrain as column heights instead of png --- src/main.js | 15 +++----- src/net/protocol.js | 2 +- src/terrain.js | 88 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/main.js b/src/main.js index 4173a93..51b28ad 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ import {clamp, deg2rad, distance, parable, random, randomInt, vec, wrap} from '. import {PROJECTILE_TYPES} from './projectiles.js?v=26'; import {generateSky} from './sky.js?v=26'; import {playTickSound} from './sound.js?v=26'; -import {clipTerrain, closestLand, collapseTerrain, generateTerrain, isTerrain, landHeight, startCollapseTerrain, collapseTerrainStep, cacheImageData} from './terrain.js?v=26'; +import {cacheImageData, clipTerrain, closestLand, collapseTerrain, decodeTerrain, encodeTerrain, generateTerrain, isTerrain, landHeight, startCollapseTerrain, collapseTerrainStep} from './terrain.js?v=26'; import {sample, shuffle, newPlayerId} from './utils.js?v=26'; import {EXPLOSION_TYPES} from './weapons.js?v=26'; import {drawPanelBg, drawPanelTitle, drawPanelTitleFancy, drawPanelText, drawPanelDivider, drawPanelMenu, getPanelBounds, drawButton, checkHit, PANEL_X, PANEL_WIDTH} from './panel.js?v=26'; @@ -760,7 +760,7 @@ netOnMessage((msg) => { return; } if (msg.type === HOST_MSG.TERRAIN) { - applyTerrain(msg.png); + applyTerrain(msg.terrain); return; } if (msg.type === HOST_MSG.END_GAME) { @@ -915,13 +915,8 @@ function interpolateWorld() { } } -function applyTerrain(png) { - const img = new Image(); - img.onload = () => { - terrain.clearRect(0, 0, W, H); - terrain.drawImage(img, 0, 0); - }; - img.src = png; +function applyTerrain(grid) { + decodeTerrain(terrain, grid); } function broadcastWorld() { @@ -973,7 +968,7 @@ function broadcastTerrain(force) { if (!force && now - lastTerrainAt < 500) return; terrainDirty = false; lastTerrainAt = now; - netBroadcast({type: HOST_MSG.TERRAIN, png: terrain.canvas.toDataURL('image/png')}); + netBroadcast({type: HOST_MSG.TERRAIN, terrain: encodeTerrain(terrain)}); } function applyCommand(playerId, cmd) { diff --git a/src/net/protocol.js b/src/net/protocol.js index b806002..f2550a7 100644 --- a/src/net/protocol.js +++ b/src/net/protocol.js @@ -32,7 +32,7 @@ export const CMD = { export const HOST_MSG = { LOBBY: 'lobby', // {players: [{id, name, ready}], config: {players, rounds, terrain}} WORLD: 'world', // {snap} - TERRAIN: 'terrain', // {png: base64} + TERRAIN: 'terrain', // {terrain: {palette: [hex], tops: b64(Uint16), colors: b64(Uint8)}} column-heights EVENT: 'event', // {kind, ...} START_ROUND: 'start-round', // {} round N begins END_GAME: 'end-game', // {reason} diff --git a/src/terrain.js b/src/terrain.js index b885833..9b202c5 100644 --- a/src/terrain.js +++ b/src/terrain.js @@ -9,6 +9,94 @@ export function cacheImageData(ctx) { cachedImageData = ctx.getImageData(0, 0, width, height); } +const rgbaToHex = (r, g, b) => '#' + + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); + +const hexToRgb = (hex) => [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), +]; + +function toBase64(bytes) { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +function fromBase64(b64) { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function encodeTerrain(ctx) { + const {width, height} = ctx.canvas; + const data = ctx.getImageData(0, 0, width, height).data; + const tops = new Uint16Array(width); + const colorIdx = new Uint8Array(width); + const palette = []; + const paletteIndex = new Map(); + const indexColor = (hex) => { + let idx = paletteIndex.get(hex); + if (idx === undefined) { + idx = palette.length; + paletteIndex.set(hex, idx); + palette.push(hex); + } + return idx; + }; + + for (let x = 0; x < width; x++) { + let top = height; + let idx = 0; + for (let y = 0; y < height; y++) { + const px = (y * width + x) * 4; + if (data[px + 3] > 0) { + top = y; + idx = indexColor(rgbaToHex(data[px], data[px + 1], data[px + 2])); + break; + } + } + tops[x] = top; + colorIdx[x] = idx; + } + + return { + palette, + tops: toBase64(new Uint8Array(tops.buffer)), + colors: toBase64(colorIdx), + }; +} + +export function decodeTerrain(ctx, grid) { + const {width, height} = ctx.canvas; + const paletteRgb = grid.palette.map(hexToRgb); + const tops = new Uint16Array(fromBase64(grid.tops).buffer); + const colorIdx = new Uint8Array(fromBase64(grid.colors)); + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + + for (let x = 0; x < width; x++) { + const top = Math.min(tops[x], height); + const [r, g, b] = paletteRgb[colorIdx[x]] || paletteRgb[0] || [0, 0, 0]; + for (let y = top; y < height; y++) { + const px = (y * width + x) * 4; + data[px] = r; + data[px + 1] = g; + data[px + 2] = b; + data[px + 3] = 255; + } + } + + ctx.putImageData(imageData, 0, 0); + cacheImageData(ctx); +} + export function generateTerrain(ctx, type) { const generator = type? TERRAIN_TYPES[type] : sample(Object.values(TERRAIN_TYPES)); const {width, height} = ctx.canvas;