Skip to content

Commit 303d375

Browse files
committed
add Advanced blocks (PenguinMod compatibility)
1 parent f3a35df commit 303d375

13 files changed

Lines changed: 1300 additions & 4 deletions

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
scratch-render modified for use in CodeTorch. We've optimized some operations and added a lot of options.
44

5+
## PenguinMod compatibility
6+
7+
This renderer (`ct-pm-*` files) reproduces the behaviour of [PenguinMod](https://github.com/PenguinMod)'s blocks ("Advanced blocks") so that PenguinMod projects (`.pmp`) can be opened in CodeTorch. The implementation is written independently on top of TurboWarp; PenguinMod's source was used as a description of the behaviour and its VM as a test oracle. PenguinMod-Render is licensed under the same license as this repository.
8+
9+
Supported: PenguinMod as of the commits below. We don't track PenguinMod; projects using anything newer still open, but blocks we don't know yet become inert placeholders.
10+
11+
| Repository | Commit | Date |
12+
|---|---|---|
13+
| [PenguinMod-Vm](https://github.com/PenguinMod/PenguinMod-Vm) | `50fddfed34f4eed9eef28ef32bc1022397d29f46` | 2026-10-26 |
14+
| [PenguinMod-Blocks](https://github.com/PenguinMod/PenguinMod-Blocks) | `de35ce5758aeda5aab0a9ad59736e1920205804b` | 2026-05-29 |
15+
| [PenguinMod-Render](https://github.com/PenguinMod/PenguinMod-Render) | `89a587aff84e0a62daf907fba464d7e02f995027` | 2026-05-29 |
16+
| [PenguinMod-Storage](https://github.com/PenguinMod/PenguinMod-Storage) | `687019085e83fc83d39f3b96b41f229e8d95d6bc` | 2026-02-21 |
17+
| [penguinmod.github.io](https://github.com/PenguinMod/penguinmod.github.io) (GUI) | `8d622ab3808c85413af85e1fac8ea7f6bb42a901` | 2026-10-20 |
18+
519
## Setup
620

721
See https://github.com/TurboWarp/scratch-gui/wiki/Getting-Started to setup the complete TurboWarp environment.

‎src/Drawable.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const Rectangle = require('./Rectangle');
44
const RenderConstants = require('./RenderConstants');
55
const ShaderManager = require('./ShaderManager');
66
const EffectTransform = require('./EffectTransform');
7+
const ctPmEffects = require('./ct-pm-effects');
8+
const ctPmCamera = require('./ct-pm-camera');
79
const log = require('./util/log');
810

911
/**
@@ -187,6 +189,7 @@ class Drawable {
187189
* @returns {Array<number>} the current scaling percentages applied to this Drawable. [100,100] is normal size.
188190
*/
189191
get scale () {
192+
if (this._ctPmCamera) return ctPmCamera.viewScale(this);
190193
return [this._scale[0], this._scale[1]];
191194
}
192195

@@ -285,6 +288,7 @@ class Drawable {
285288
if (effectInfo.shapeChanges) {
286289
this.setConvexHullDirty();
287290
}
291+
if (effectInfo.geometry) ctPmEffects.updateGeometry(this);
288292
}
289293

290294
/**
@@ -454,6 +458,8 @@ class Drawable {
454458
modelMatrix[13] = (rotation01 * adjusted0) + (rotation11 * adjusted1) + position1;
455459
// modelMatrix[14] = 0;
456460
// modelMatrix[15] = 1;
461+
if (this._ctPmGeometry) ctPmEffects.applyGeometry(this, modelMatrix);
462+
if (this._ctPmCamera) ctPmCamera.applyView(this, modelMatrix);
457463

458464
this._transformDirty = false;
459465
}

‎src/EffectTransform.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const twgl = require('twgl.js');
88

99
const {rgbToHsv, hsvToRgb} = require('./util/color-conversions');
1010
const ShaderManager = require('./ShaderManager');
11+
const ctPmEffects = require('./ct-pm-effects');
1112

1213
/**
1314
* A texture coordinate is between 0 and 1. 0.5 is the center position.
@@ -45,6 +46,7 @@ class EffectTransform {
4546

4647
let effects = drawable.enabledEffects;
4748
if (typeof effectMask === 'number') effects &= effectMask;
49+
if (effects & ctPmEffects.masks.colour) return ctPmEffects.transformColor(drawable, inOutColor, effects);
4850
const uniforms = drawable.getUniforms();
4951

5052
const enableColor = (effects & ShaderManager.EFFECT_INFO.color.mask) !== 0;
@@ -189,6 +191,9 @@ class EffectTransform {
189191
dst[0] = CENTER_X + (r * unitX * CENTER_X);
190192
dst[1] = CENTER_Y + (r * unitY * CENTER_Y);
191193
}
194+
if ((effects & (ctPmEffects.masks.repeatX | ctPmEffects.masks.repeatY)) !== 0) {
195+
ctPmEffects.transformPoint(drawable, dst, effects);
196+
}
192197

193198
return dst;
194199
}

‎src/RenderWebGL.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const TextBubbleSkin = require('./TextBubbleSkin');
1616
const EffectTransform = require('./EffectTransform');
1717
const CanvasMeasurementProvider = require('./util/canvas-measurement-provider');
1818
const log = require('./util/log');
19+
const ctPmCamera = require('./ct-pm-camera');
1920

2021
const __isTouchingDrawablesPoint = twgl.v3.create();
2122
const __candidatesBounds = new Rectangle();
@@ -1876,6 +1877,8 @@ class RenderWebGL extends EventEmitter {
18761877
return [x, y];
18771878
}
18781879

1880+
if (drawable._ctPmCamera) return ctPmCamera.fencedPosition(this, drawable, position, __fenceBounds);
1881+
18791882
const dx = x - drawable._position[0];
18801883
const dy = y - drawable._position[1];
18811884
const aabb = drawable._skin.getFenceBounds(drawable, __fenceBounds);
@@ -2460,4 +2463,7 @@ RenderWebGL.UseGpuModes = {
24602463
*/
24612464
RenderWebGL.powerPreference = 'default';
24622465

2466+
require('./ct-pm-render')(RenderWebGL);
2467+
ctPmCamera.install(RenderWebGL);
2468+
24632469
module.exports = RenderWebGL;

‎src/SVGSkin.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const twgl = require('twgl.js');
33
const Skin = require('./Skin');
44
const {loadSvgString, serializeSvgToString} = require('@turbowarp/scratch-svg-renderer');
55
const ShaderManager = require('./ShaderManager');
6+
const ctPmCamera = require('./ct-pm-camera');
67

78
/**
89
* All scaled renderings of the SVG are stored in an array. The 1.0 scale of
@@ -82,6 +83,7 @@ class SVGSkin extends Skin {
8283
if (drawable._direction % 90 !== 0) {
8384
return false;
8485
}
86+
if (ctPmCamera.rotated(drawable)) return false;
8587

8688
// Because SVG skins' bounding boxes are currently not pixel-aligned, the idea here is to hide blurriness
8789
// by using nearest-neighbor scaling if one screen-space pixel is "close enough" to one texture pixel.

‎src/ShaderManager.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ ShaderManager.EFFECT_INFO = {
156156
}
157157
};
158158

159+
require('./ct-pm-effects').register(ShaderManager.EFFECT_INFO);
160+
159161
/**
160162
* The name of each supported effect.
161163
* @type {Array}

‎src/ct-pm-camera.js‎

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
const FENCE_WIDTH = 15;
2+
3+
/**
4+
* Non-finite values.
5+
* @param {*} x value
6+
* @param {number} fallback fallback
7+
* @returns {number} x or fallback
8+
*/
9+
const finite = (x, fallback) => (typeof x === 'number' && Number.isFinite(x) ? x : fallback);
10+
11+
class CameraView {
12+
constructor (name) {
13+
this.name = name;
14+
this.x = 0;
15+
this.y = 0;
16+
this.dir = 0;
17+
this.scale = 1;
18+
this.a = 1;
19+
this.b = 0;
20+
this.active = false;
21+
this.drawables = new Set();
22+
this._sweepAt = 64;
23+
}
24+
25+
set (state) {
26+
this.x = finite(state && state.x, 0);
27+
this.y = finite(state && state.y, 0);
28+
this.dir = finite(state && state.dir, 0);
29+
this.scale = finite(state && state.scale, 1);
30+
const radians = this.dir * Math.PI / 180;
31+
this.a = this.scale * Math.cos(radians);
32+
this.b = this.scale * Math.sin(radians);
33+
this.active = !(this.x === 0 && this.y === 0 && this.dir === 0 && this.scale === 1);
34+
}
35+
}
36+
37+
/**
38+
* Premultiply a drawable's model matrix.
39+
* @param {Drawable} drawable the drawable, with an active `_ctPmCamera`
40+
* @param {Float32Array} m m
41+
*/
42+
const applyView = (drawable, m) => {
43+
const view = drawable._ctPmCamera;
44+
const a = view.a;
45+
const b = view.b;
46+
const m0 = m[0];
47+
const m1 = m[1];
48+
const m4 = m[4];
49+
const m5 = m[5];
50+
m[0] = (a * m0) - (b * m1);
51+
m[1] = (b * m0) + (a * m1);
52+
m[4] = (a * m4) - (b * m5);
53+
m[5] = (b * m4) + (a * m5);
54+
55+
const px = drawable._position[0] - view.x;
56+
const py = drawable._position[1] - view.y;
57+
let sx = (a * px) - (b * py);
58+
let sy = (b * px) + (a * py);
59+
if (!drawable._highQuality) {
60+
sx = Math.round(sx);
61+
sy = Math.round(sy);
62+
}
63+
const ox = m[12] - drawable._position[0];
64+
const oy = m[13] - drawable._position[1];
65+
m[12] = (a * ox) - (b * oy) + sx;
66+
m[13] = (b * ox) + (a * oy) + sy;
67+
};
68+
69+
/**
70+
* The drawable's scale as seen on screen, for choosing texture resolution.
71+
* @param {Drawable} drawable the drawable, with an active `_ctPmCamera`
72+
* @returns {Array<number>} [x, y] scale in percent
73+
*/
74+
const viewScale = drawable => {
75+
const zoom = Math.abs(drawable._ctPmCamera.scale);
76+
return [drawable._scale[0] * zoom, drawable._scale[1] * zoom];
77+
};
78+
79+
/**
80+
* @param {Drawable} drawable a drawable
81+
* @returns {boolean} true true
82+
*/
83+
const rotated = drawable => !!drawable._ctPmCamera && drawable._ctPmCamera.dir % 90 !== 0;
84+
85+
/**
86+
* RenderWebGL#getFencedPositionOfDrawable for a drawable on a camera.
87+
* @param {RenderWebGL} renderer the renderer
88+
* @param {Drawable} drawable the drawable, with an active `_ctPmCamera`
89+
* @param {Array<number>} position requested world position
90+
* @param {Rectangle} fenceBounds scratch rectangle
91+
* @returns {Array<number>} fenced world position
92+
*/
93+
const fencedPosition = (renderer, drawable, position, fenceBounds) => {
94+
const view = drawable._ctPmCamera;
95+
const toScreen = (x, y) => {
96+
const dx = x - view.x;
97+
const dy = y - view.y;
98+
return [(view.a * dx) - (view.b * dy), (view.b * dx) + (view.a * dy)];
99+
};
100+
const target = toScreen(position[0], position[1]);
101+
const current = toScreen(drawable._position[0], drawable._position[1]);
102+
let x = target[0];
103+
let y = target[1];
104+
const dx = x - current[0];
105+
const dy = y - current[1];
106+
const aabb = drawable._skin.getFenceBounds(drawable, fenceBounds);
107+
const inset = Math.floor(Math.min(aabb.width, aabb.height) / 2);
108+
const sx = renderer._xRight - Math.min(FENCE_WIDTH, inset);
109+
if (aabb.right + dx < -sx) {
110+
x = Math.ceil(current[0] - (sx + aabb.right));
111+
} else if (aabb.left + dx > sx) {
112+
x = Math.floor(current[0] + (sx - aabb.left));
113+
}
114+
const sy = renderer._yTop - Math.min(FENCE_WIDTH, inset);
115+
if (aabb.top + dy < -sy) {
116+
y = Math.ceil(current[1] - (sy + aabb.top));
117+
} else if (aabb.bottom + dy > sy) {
118+
y = Math.floor(current[1] + (sy - aabb.bottom));
119+
}
120+
if (x === target[0] && y === target[1]) return [position[0], position[1]];
121+
const det = (view.a * view.a) + (view.b * view.b);
122+
if (det === 0) return [position[0], position[1]];
123+
return [
124+
view.x + (((view.a * x) + (view.b * y)) / det),
125+
view.y + (((view.a * y) - (view.b * x)) / det)
126+
];
127+
};
128+
129+
/**
130+
* Point a drawable at the camera object it should use.
131+
* @param {RenderWebGL} renderer the renderer
132+
* @param {Drawable} drawable the drawable
133+
* @param {?CameraView} view its camera
134+
* @param {boolean} [force] force
135+
*/
136+
const attach = (renderer, drawable, view, force) => {
137+
const next = view && view.active ? view : null;
138+
if (drawable._ctPmCamera === next && !(force && next)) {
139+
return;
140+
}
141+
drawable._ctPmCamera = next;
142+
drawable.setTransformDirty();
143+
renderer.dirty = true;
144+
};
145+
146+
const methods = {
147+
/**
148+
* @param {string} name camera name
149+
* @param {boolean} create make it if it doesn't exist
150+
* @returns {?CameraView} the camera
151+
* @private
152+
*/
153+
_ctPmCamera (name, create) {
154+
if (!this._ctPmCameras) {
155+
if (!create) return null;
156+
this._ctPmCameras = new Map();
157+
}
158+
let view = this._ctPmCameras.get(name);
159+
if (!view && create) {
160+
view = new CameraView(name);
161+
this._ctPmCameras.set(name, view);
162+
}
163+
return view || null;
164+
},
165+
166+
/**
167+
* Set a camera.
168+
* @param {string} name camera name
169+
* @param {?{x: number, y: number, dir: number, scale: number}} state state
170+
*/
171+
ctPmSetCamera (name, state) {
172+
name = String(name);
173+
const view = this._ctPmCamera(name, !!state);
174+
if (!view) return;
175+
const wasActive = view.active;
176+
const before = [view.x, view.y, view.dir, view.scale];
177+
view.set(state);
178+
if (!wasActive && !view.active) return;
179+
if (before[0] === view.x && before[1] === view.y && before[2] === view.dir && before[3] === view.scale) return;
180+
for (const id of view.drawables) {
181+
const drawable = this._allDrawables[id];
182+
if (!drawable || drawable._ctPmCameraName !== name) {
183+
view.drawables.delete(id);
184+
continue;
185+
}
186+
attach(this, drawable, view, true);
187+
}
188+
this.dirty = true;
189+
},
190+
191+
/**
192+
* Bind a drawable to a camera.
193+
* @param {int} drawableID the drawable
194+
* @param {?string} name camera name
195+
*/
196+
ctPmSetDrawableCamera (drawableID, name) {
197+
const drawable = this._allDrawables[drawableID];
198+
if (!drawable) return;
199+
name = (name === null || typeof name === 'undefined') ? null : String(name);
200+
const current = typeof drawable._ctPmCameraName === 'undefined' ? null : drawable._ctPmCameraName;
201+
if (current === name) return;
202+
if (current !== null) {
203+
const old = this._ctPmCamera(current, false);
204+
if (old) old.drawables.delete(drawableID);
205+
}
206+
drawable._ctPmCameraName = name;
207+
let view = null;
208+
if (name !== null) {
209+
view = this._ctPmCamera(name, true);
210+
view.drawables.add(drawableID);
211+
if (view.drawables.size >= view._sweepAt) {
212+
for (const id of view.drawables) {
213+
const d = this._allDrawables[id];
214+
if (!d || d._ctPmCameraName !== name) view.drawables.delete(id);
215+
}
216+
view._sweepAt = Math.max(64, view.drawables.size * 2);
217+
}
218+
}
219+
attach(this, drawable, view, false);
220+
},
221+
222+
/**
223+
* @param {string} name camera name
224+
* @returns {{x: number, y: number, dir: number, scale: number}} the camera as currently drawn
225+
*/
226+
ctPmGetCamera (name) {
227+
const view = this._ctPmCamera(String(name), false);
228+
if (!view) return {x: 0, y: 0, dir: 0, scale: 1};
229+
return {x: view.x, y: view.y, dir: view.dir, scale: view.scale};
230+
}
231+
};
232+
233+
/**
234+
* @param {Function} RenderWebGL RenderWebGL
235+
*/
236+
const install = RenderWebGL => {
237+
for (const name of Object.keys(methods)) {
238+
RenderWebGL.prototype[name] = methods[name];
239+
}
240+
};
241+
242+
module.exports = {
243+
install,
244+
applyView,
245+
viewScale,
246+
rotated,
247+
fencedPosition,
248+
CameraView
249+
};

0 commit comments

Comments
 (0)