From 3e497df766ec492a5e55237719857adea6ebc6bd Mon Sep 17 00:00:00 2001 From: thisisuhhplanetring Date: Thu, 10 Sep 2026 21:26:37 +0300 Subject: [PATCH 1/2] Support 2.2 object scaling (128/129) and warp (131/132) Levels built in 2.2 use per-axis scale and warp everywhere, and neither survived the trip into Web Dashers: 128/129 and 131/132 were not parsed at all, so a scaled or warped object loaded at its default size and rotation. Key 32 was parsed but only ever reached the sprite, never the hitbox. Parsing 128/129 are the two scale axes. 2.2 writes them instead of key 32, and writes them independently, so each falls back to 32 and 32 falls back to 1. 131/132 are the object's cocos2d rotationX and rotationY - what the editor calls warp. Equal values are an ordinary rotation, and 2.2 writes the pair INSTEAD of key 6, so the pair wins over key 6 rather than the reverse. They are stored as one rotation plus the spread between the axes, which keeps a warped object's warp when something later rotates it. Rendering Phaser builds a sprite matrix from rotation + scaleX + scaleY, three numbers that can only describe a rectangle. A warped object is a parallelogram, so a warped sprite holds an identity transform of its own and its render call is wrapped to pass the renderer a parent matrix with the real transform folded in. Tint, flip, blend mode, depth and origin are untouched, and the formula reduces to Phaser's own applyITRS when the two axis angles are equal. Hitboxes Solids, hazards, slopes, portals, pads, orbs, coins and speed portals all size from the object's scale now. Warp is deliberately not applied: in GD the hitbox stays a rectangle however far the sprite is sheared. Enter effects updateEnterEffects, updateAudioScale and the orb pulse animate a scale factor and used to write it straight onto the sprite, which assumed every object sits at scale 1 - so a scaled object snapped to full size the moment it entered the screen. This is why key 32 never visibly worked either. They multiply by the object's own scale now. Editor _serializeObject writes 128/129 and 131/132 back, and keeps old levels on key 32 and key 6 so they round-trip unchanged. Verified against level 22, which has 11,897 scaled and 487 warped objects, and against a purpose-built level covering each key alone and combined: a 45 degree turn written as key 6 and as 131/132 renders and collides identically, and every case survives a save/reload. --- assets/scripts/core/level-editor.js | 37 ++++- assets/scripts/core/level.js | 224 ++++++++++++++++++++++------ 2 files changed, 211 insertions(+), 50 deletions(-) diff --git a/assets/scripts/core/level-editor.js b/assets/scripts/core/level-editor.js index b9fb83fe..01336458 100644 --- a/assets/scripts/core/level-editor.js +++ b/assets/scripts/core/level-editor.js @@ -8607,9 +8607,42 @@ _serializeObject(object) { objectData[3] = String(object.y ?? 0); objectData[4] = object.flipX ? "1" : "0"; objectData[5] = object.flipY ? "1" : "0"; - objectData[6] = String(object.rot ?? 0); - objectData[32] = String(object.scale ?? 1); + const dropKey = (key) => { + delete objectData[key]; + delete objectData[String(key)]; + }; + const numberOr = (value, fallback) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const rotation = numberOr(object.rot, 0); + const warp = numberOr(object.warp, 0); + if (warp) { + dropKey(6); + objectData[131] = String(rotation - warp / 2); + objectData[132] = String(rotation + warp / 2); + } else { + objectData[6] = String(rotation); + dropKey(131); + dropKey(132); + } + + const uniformScale = numberOr(object.scale, 1); + const scaleX = numberOr(object.scaleX, uniformScale); + const scaleY = numberOr(object.scaleY, uniformScale); + const hadAxisScale = object._raw?.[128] !== undefined || object._raw?.["128"] !== undefined + || object._raw?.[129] !== undefined || object._raw?.["129"] !== undefined; + if (scaleX !== scaleY || hadAxisScale) { + dropKey(32); + objectData[128] = String(scaleX); + objectData[129] = String(scaleY); + } else { + objectData[32] = String(scaleX); + dropKey(128); + dropKey(129); + } objectData[20] = String(object.editorLayer ?? object._raw?.[20] ?? object._raw?.["20"] ?? 0); objectData[24] = String(object.zLayer ?? 0); diff --git a/assets/scripts/core/level.js b/assets/scripts/core/level.js index 087b3249..817e1797 100644 --- a/assets/scripts/core/level.js +++ b/assets/scripts/core/level.js @@ -99,6 +99,10 @@ function parseObject(objectString) { } let objectId = parseInt(objectData[1] || "0", 10); const rawGroupValues = []; + const readFloat = (key, fallback) => { + const value = parseFloat(objectData[key]); + return Number.isFinite(value) ? value : fallback; + }; const addRawGroups = (rawValue) => { String(rawValue ?? "") .split(".") @@ -109,6 +113,18 @@ function parseObject(objectString) { addRawGroups(objectData[33]); addRawGroups(objectData[57]); const groupString = [...new Set(rawGroupValues)].join("."); + + const legacyScale = readFloat(32, 1); + const scaleX = readFloat(128, legacyScale); + const scaleY = readFloat(129, legacyScale); + + const baseRotation = readFloat(6, 0); + const rotationX = readFloat(131, baseRotation); + const rotationY = readFloat(132, baseRotation); + + const warp = ((rotationY - rotationX) % 360 + 540) % 360 - 180; + const rotation = rotationX + warp / 2; + if (objectId === 0) { return null; } else { @@ -118,8 +134,11 @@ function parseObject(objectString) { y: parseFloat(objectData[3] || "0"), flipX: objectData[4] === "1", flipY: objectData[5] === "1", - rot: parseFloat(objectData[6] || "0"), - scale: parseFloat(objectData[32] || "1"), + rot: rotation, + scale: legacyScale, + scaleX: scaleX, + scaleY: scaleY, + warp: warp, editorLayer: parseInt(objectData[20] || "0", 10), zLayer: parseInt(objectData[24] || "0", 10), zOrder: parseInt(objectData[25] || "0", 10), @@ -396,6 +415,107 @@ function _isDecorationSlopeId(id) { return false; } +function gdObjectScale(levelObj) { + const uniform = Number(levelObj?.scale); + const fallback = Number.isFinite(uniform) ? uniform : 1; + const scaleX = Number(levelObj?.scaleX); + const scaleY = Number(levelObj?.scaleY); + return { + x: Number.isFinite(scaleX) ? scaleX : fallback, + y: Number.isFinite(scaleY) ? scaleY : fallback + }; +} + +function gdObjectWarp(levelObj) { + const warp = Number(levelObj?.warp); + return Number.isFinite(warp) ? warp : 0; +} + +function gdWarpAxes(rotationDeg, warpDeg) { + const toRad = Math.PI / 180; + return { + rotX: (rotationDeg - warpDeg / 2) * toRad, + rotY: (rotationDeg + warpDeg / 2) * toRad + }; +} + +function gdTransformLocalPoint(x, y, rotationDeg, scaleX, scaleY, warpDeg) { + const { rotX, rotY } = gdWarpAxes(rotationDeg, warpDeg); + return { + x: Math.cos(rotY) * scaleX * x - Math.sin(rotX) * scaleY * y, + y: Math.sin(rotY) * scaleX * x + Math.cos(rotX) * scaleY * y + }; +} + +const _gdWarpLinear = new Phaser.GameObjects.Components.TransformMatrix(); +const _gdWarpParent = new Phaser.GameObjects.Components.TransformMatrix(); + +function _gdWarpParentMatrix(sprite, camera, parentMatrix) { + const warp = sprite._gdWarp; + const rotX = warp.rotationX; + const rotY = warp.rotationY; + + _gdWarpLinear.setTransform( + Math.cos(rotY) * warp.scaleX, Math.sin(rotY) * warp.scaleX, + -Math.sin(rotX) * warp.scaleY, Math.cos(rotX) * warp.scaleY, + 0, 0 + ); + + let originX = sprite.x; + let originY = sprite.y; + if (camera.roundPixels) { + originX = Math.floor(originX); + originY = Math.floor(originY); + } + + if (parentMatrix) _gdWarpParent.copyFrom(parentMatrix); + else _gdWarpParent.loadIdentity(); + + _gdWarpParent.translate(originX, originY); + _gdWarpParent.multiply(_gdWarpLinear, _gdWarpParent); + _gdWarpParent.translate(-originX, -originY); + return _gdWarpParent; +} + +function gdApplyEffectScale(sprite, factor) { + const nextX = factor * (sprite._eeBaseScaleX ?? 1); + const nextY = factor * (sprite._eeBaseScaleY ?? 1); + if (sprite.scaleX !== nextX || sprite.scaleY !== nextY) sprite.setScale(nextX, nextY); +} + +function applyGDObjectTransform(sprite, rotationDeg, scaleX, scaleY, warpDeg) { + if (!sprite) return sprite; + + if (!warpDeg) { + if (rotationDeg) sprite.setAngle(rotationDeg); + if (scaleX !== 1 || scaleY !== 1) sprite.setScale(scaleX, scaleY); + sprite._eeBaseScaleX = scaleX; + sprite._eeBaseScaleY = scaleY; + return sprite; + } + + sprite._eeBaseScaleX = 1; + sprite._eeBaseScaleY = 1; + + const { rotX, rotY } = gdWarpAxes(rotationDeg, warpDeg); + sprite._gdWarp = { rotationX: rotX, rotationY: rotY, scaleX: scaleX, scaleY: scaleY }; + + if (sprite._gdWarpHooked) return sprite; + sprite._gdWarpHooked = true; + + const drawWebGL = sprite.renderWebGL; + const drawCanvas = sprite.renderCanvas; + + sprite.renderWebGL = function (renderer, src, camera, parentMatrix) { + drawWebGL.call(this, renderer, src, camera, _gdWarpParentMatrix(src, camera, parentMatrix)); + }; + sprite.renderCanvas = function (renderer, src, camera, parentMatrix) { + drawCanvas.call(this, renderer, src, camera, _gdWarpParentMatrix(src, camera, parentMatrix)); + }; + + return sprite; +} + function rotateSlopePoint(localX, localY, rotDeg) { const theta = -rotDeg * Math.PI / 180; const cosT = Math.cos(theta), sinT = Math.sin(theta); @@ -407,10 +527,11 @@ function _createSlopeCollider(levelObj, objectDef, worldX, worldY) { if (!slopeData) return null; if (slopeData.sq) return null; if (_isDecorationSlopeId(levelObj.id)) return null; - const _scaleRaw = Number(levelObj.scale); - const _scale = Number.isFinite(_scaleRaw) && _scaleRaw > 0 ? _scaleRaw : 1; - const hw0 = (slopeData.gw * a * _scale) / 2; - const hh0 = (slopeData.gh * a * _scale) / 2; + const _scale = gdObjectScale(levelObj); + const _scaleX = Math.abs(_scale.x) > 0 ? Math.abs(_scale.x) : 1; + const _scaleY = Math.abs(_scale.y) > 0 ? Math.abs(_scale.y) : 1; + const hw0 = (slopeData.gw * a * _scaleX) / 2; + const hh0 = (slopeData.gh * a * _scaleY) / 2; let vRight = { x: hw0, y: -hh0 }; let vLo = { x: -hw0, y: -hh0 }; let vHi = { x: hw0, y: hh0 }; @@ -1608,21 +1729,18 @@ window.LevelObject = class LevelObject { offsetY = -offsetY; } let totalRotation = (sprite.getData("gjBaseRotationDeg") || 0) + objectData.rot; - if (totalRotation !== 0) { - sprite.setAngle(totalRotation); - let rad = totalRotation * Math.PI / 180; - let cosR = Math.cos(rad); - let sinR = Math.sin(rad); - let rx = offsetX * cosR - offsetY * sinR; - let ry = offsetX * sinR + offsetY * cosR; - offsetX = rx; - offsetY = ry; + const objectScale = gdObjectScale(objectData); + const objectWarp = gdObjectWarp(objectData); + if (totalRotation !== 0 || objectScale.x !== 1 || objectScale.y !== 1 || objectWarp !== 0) { + const placed = gdTransformLocalPoint( + offsetX, offsetY, totalRotation, objectScale.x, objectScale.y, objectWarp + ); + offsetX = placed.x; + offsetY = placed.y; } sprite.x += offsetX; sprite.y += offsetY; - if (objectData.scale !== 1) { - sprite.setScale(objectData.scale); - } + applyGDObjectTransform(sprite, totalRotation, objectScale.x, objectScale.y, objectWarp); if (colorData) { const blackDefault = colorData.black === true || colorData.tint === 0; if (colorData.tint !== undefined) { @@ -1952,9 +2070,14 @@ window.LevelObject = class LevelObject { }).setOrigin(0.5); } - const scale = Number.isFinite(Number(levelObj.scale)) ? Number(levelObj.scale) : 1; - textSprite.setScale(scale * (levelObj.flipX ? -1 : 1), scale * (levelObj.flipY ? -1 : 1)); - textSprite.setAngle(levelObj.rot || 0); + const textScale = gdObjectScale(levelObj); + applyGDObjectTransform( + textSprite, + levelObj.rot || 0, + textScale.x * (levelObj.flipX ? -1 : 1), + textScale.y * (levelObj.flipY ? -1 : 1), + gdObjectWarp(levelObj) + ); const depthBase = { "-5": -12, "-3": -9, "-1": -6, 0: 0, 1: 3, 3: 6, 5: 9, 7: 10.5, 9: 12, 11: 13.5 }; const zLayer = parseInt(levelObj.zLayer ?? objectDef?.default_z_layer ?? 3, 10) || 0; @@ -2457,7 +2580,7 @@ window.LevelObject = class LevelObject { } if (objectDef && objectDef.type === ringType) { - sprite.setScale(0.75); + gdApplyEffectScale(sprite, 0.75); sprite._eeAudioScale = true; sprite._orbId = levelObj.id; this._orbSprites.push(sprite); @@ -2468,7 +2591,7 @@ window.LevelObject = class LevelObject { } if (orbGlow) { - orbGlow.setScale(0.75); + gdApplyEffectScale(orbGlow, 0.75); orbGlow._eeAudioScale = true; orbGlow._orbId = levelObj.id; this._orbSprites.push(orbGlow); @@ -2867,6 +2990,11 @@ window.LevelObject = class LevelObject { } }; + const hitScale = gdObjectScale(levelObj); + const hitScaleX = Math.abs(hitScale.x); + const hitScaleY = Math.abs(hitScale.y); + const hitScaleMean = (hitScaleX + hitScaleY) / 2; + const slopeCollider = _createSlopeCollider(levelObj, objectDef, worldX, worldY); if (slopeCollider) { registerCollider(slopeCollider); @@ -2874,8 +3002,8 @@ window.LevelObject = class LevelObject { hasCollisionEntry = true; this._addCollisionToSection(slopeCollider); } else if (objectDef.type === solidType && objectDef.gridW > 0 && objectDef.gridH > 0) { - const w = objectDef.gridW * a; - const h = objectDef.gridH * a; + const w = objectDef.gridW * a * hitScaleX; + const h = objectDef.gridH * a * hitScaleY; const collider = new Collider(solidType, worldX, worldY, w, h, levelObj.rot || 0); collider.objid = levelObj.id; registerCollider(collider); @@ -2892,15 +3020,15 @@ window.LevelObject = class LevelObject { objectDef.hitboxScaleX !== undefined && objectDef.hitboxScaleY !== undefined ) { - hitW = objectDef.spriteW * objectDef.hitboxScaleX * 2; - hitH = objectDef.spriteH * objectDef.hitboxScaleY * 2; + hitW = objectDef.spriteW * objectDef.hitboxScaleX * 2 * hitScaleX; + hitH = objectDef.spriteH * objectDef.hitboxScaleY * 2 * hitScaleY; } else if (objectDef.gridW > 0 && objectDef.gridH > 0) { - hitW = objectDef.gridW * 12; - hitH = objectDef.gridH * 24; + hitW = objectDef.gridW * 12 * hitScaleX; + hitH = objectDef.gridH * 24 * hitScaleY; } const hasHitboxRadius = objectDef.hitbox_radius !== undefined && objectDef.hitbox_radius !== null; - const worldHitboxRadius = hasHitboxRadius ? objectDef.hitbox_radius * 2 : 0; + const worldHitboxRadius = hasHitboxRadius ? objectDef.hitbox_radius * 2 * hitScaleMean : 0; if (hasHitboxRadius && hitW === 0) { hitW = worldHitboxRadius * 2; @@ -2916,8 +3044,8 @@ window.LevelObject = class LevelObject { this._addCollisionToSection(collider); } } else if (objectDef.type === portalType) { - const portalW = objectDef.gridW * a; - const portalH = objectDef.gridH * a; + const portalW = objectDef.gridW * a * hitScaleX; + const portalH = objectDef.gridH * a * hitScaleY; const portalSub = objectDef.sub || { 10: "gravity_flip", 11: "gravity_normal", @@ -2980,8 +3108,8 @@ window.LevelObject = class LevelObject { this._addCollisionToSection(collider); } } else if (objectDef.type === padType) { - const padW = objectDef.gridW * a; - const padH = objectDef.gridH * a; + const padW = objectDef.gridW * a * hitScaleX; + const padH = objectDef.gridH * a * hitScaleY; const padObj = new Collider(jumpPadType, worldX, worldY, padW, padH, levelObj.rot || 0); padObj.padId = levelObj.id; registerCollider(padObj); @@ -3063,8 +3191,8 @@ window.LevelObject = class LevelObject { this.topContainer.add(_padEmitter); } } else if (objectDef.type === ringType) { - const orbW = objectDef.gridW * a; - const orbH = objectDef.gridH * a; + const orbW = objectDef.gridW * a * hitScaleX; + const orbH = objectDef.gridH * a * hitScaleY; const orbObj = new Collider(jumpRingType, worldX, worldY, orbW, orbH, levelObj.rot || 0); orbObj.orbId = levelObj.id; orbObj.orbRotation = levelObj.rot || 0; @@ -3074,8 +3202,8 @@ window.LevelObject = class LevelObject { hasCollisionEntry = true; this._addCollisionToSection(orbObj); } else if (objectDef.type === coinType) { - const coinW = (objectDef.gridW || 1) * a; - const coinH = (objectDef.gridH || 1) * a; + const coinW = (objectDef.gridW || 1) * a * hitScaleX; + const coinH = (objectDef.gridH || 1) * a * hitScaleY; const coinObj = new Collider(coinType, worldX, worldY, coinW, coinH, levelObj.rot || 0); coinObj.coinId = levelObj.id; if (objectId === SECRET_COIN_OBJECT_ID) { @@ -3093,8 +3221,8 @@ window.LevelObject = class LevelObject { hasCollisionEntry = true; this._addCollisionToSection(coinObj); } else if (objectDef.type === speedType) { - const speedW = (objectDef.gridW || 1) * a; - const speedH = (objectDef.gridH || 1) * a; + const speedW = (objectDef.gridW || 1) * a * hitScaleX; + const speedH = (objectDef.gridH || 1) * a * hitScaleY; const speedObj = new Collider(speedType, worldX, worldY, speedW, speedH, levelObj.rot || 0); speedObj.portalY = worldY; @@ -4301,7 +4429,7 @@ window.LevelObject = class LevelObject { visMinSection.x = visMinSection._eeWorldX; visMinSection.y = visMinSection._eeBaseY; if (!visMinSection._eeAudioScale) { - visMinSection.setScale(1); + gdApplyEffectScale(visMinSection, 1); } visMinSection.setAlpha(this._getGroupOpacityForSprite(visMinSection)); } @@ -4347,7 +4475,7 @@ window.LevelObject = class LevelObject { effectSprite.y = effectSprite._eeBaseY; effectSprite.x = effectSprite._eeWorldX; if (!effectSprite._eeAudioScale) { - effectSprite.setScale(1); + gdApplyEffectScale(effectSprite, 1); } effectSprite.setAlpha(this._getGroupOpacityForSprite(effectSprite)); } @@ -4363,7 +4491,7 @@ window.LevelObject = class LevelObject { effectSprite.y = effectSprite._eeBaseY; effectSprite.x = effectSprite._eeWorldX; if (!effectSprite._eeAudioScale) { - effectSprite.setScale(1); + gdApplyEffectScale(effectSprite, 1); } effectSprite.setAlpha(this._getGroupOpacityForSprite(effectSprite)); } @@ -4411,8 +4539,8 @@ window.LevelObject = class LevelObject { if (effectSprite.alpha !== _eeFinalAlpha) { effectSprite.alpha = _eeFinalAlpha; } - if (!effectSprite._eeAudioScale && effectSprite.scaleX !== _0x127ace) { - effectSprite.setScale(_0x127ace); + if (!effectSprite._eeAudioScale) { + gdApplyEffectScale(effectSprite, _0x127ace); } } } @@ -4447,7 +4575,7 @@ window.LevelObject = class LevelObject { const _worldX = _0x24afdb._eeWorldX; if (Number.isFinite(_worldX) && (_worldX < _minVisibleX || _worldX > _maxVisibleX)) continue; if (_0x24afdb._eeLastAudioScale !== _targetAudioScale) { - _0x24afdb.setScale(_targetAudioScale); + gdApplyEffectScale(_0x24afdb, _targetAudioScale); _0x24afdb._eeLastAudioScale = _targetAudioScale; } } @@ -4474,7 +4602,7 @@ window.LevelObject = class LevelObject { } } if (_0xOrbSpr._eeLastOrbScale !== _targetScale) { - _0xOrbSpr.setScale(_targetScale); + gdApplyEffectScale(_0xOrbSpr, _targetScale); _0xOrbSpr._eeLastOrbScale = _targetScale; } } @@ -4534,7 +4662,7 @@ window.LevelObject = class LevelObject { this._secretCoinRunCollected.clear(); this._userCoinRunCollected.clear(); for (let _0x5c5d9a of this._audioScaleSprites) { - _0x5c5d9a.setScale(0.1); + gdApplyEffectScale(_0x5c5d9a, 0.1); } for (const objectSpriteList of this.objectSprites || []) { if (!objectSpriteList) continue; From 58b1de68e8290152141a1216460a192805fa944d Mon Sep 17 00:00:00 2001 From: thisisuhhplanetring Date: Thu, 10 Sep 2026 21:37:03 +0300 Subject: [PATCH 2/2] Scale composite objects' child parts with the parent An object built from several sprites positions each part from a localDx/localDy in the object's own space. That offset was rotated with the object but not scaled or warped, so a scaled composite kept its parts at unscaled distances - the pieces sat too close together while each piece drew at the right size. 2576 of the 4082 objects are composites, so this covers most of the object set: portals, orbs, pads and every decorated block. The offset now goes through the same transform as the object. It reduces to the rotation that was there before at scale 1 with no warp, so unscaled objects are untouched. --- assets/scripts/core/level.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/scripts/core/level.js b/assets/scripts/core/level.js index 817e1797..3795bf9f 100644 --- a/assets/scripts/core/level.js +++ b/assets/scripts/core/level.js @@ -2721,9 +2721,13 @@ window.LevelObject = class LevelObject { localDy = -localDy; } - const rot = (levelObj.rot || 0) * Math.PI / 180; - childDx = localDx * Math.cos(rot) - localDy * Math.sin(rot); - childDy = localDx * Math.sin(rot) + localDy * Math.cos(rot); + const childScale = gdObjectScale(levelObj); + const placed = gdTransformLocalPoint( + localDx, localDy, levelObj.rot || 0, + childScale.x, childScale.y, gdObjectWarp(levelObj) + ); + childDx = placed.x; + childDy = placed.y; } const childWorldX = worldX + childDx;