scratch-blocks 2.0.0-spork.6 → 2.0.0-spork.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +1 -1
- package/dist/main.js.LICENSE.txt +6 -0
- package/package.json +1 -1
- package/src/constants.ts +9 -0
- package/src/index.ts +21 -2
- package/src/renderer/bowler_hat.ts +3 -2
- package/src/renderer/cat/cat_face.ts +226 -0
- package/src/renderer/cat/constants.ts +136 -0
- package/src/renderer/cat/drawer.ts +63 -0
- package/src/renderer/cat/path_object.ts +16 -0
- package/src/renderer/cat/render_info.ts +15 -0
- package/src/renderer/cat/renderer.ts +34 -0
- package/src/renderer/constants.ts +16 -2
- package/src/renderer/drawer.ts +17 -10
- package/src/renderer/path_object.ts +1 -1
- package/src/renderer/render_info.ts +15 -7
- package/src/renderer/renderer.ts +16 -6
package/dist/main.js.LICENSE.txt
CHANGED
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -74,3 +74,12 @@ export { OUTPUT_SHAPE_ROUND };
|
|
|
74
74
|
*/
|
|
75
75
|
const NEW_BROADCAST_MESSAGE_ID = "NEW_BROADCAST_MESSAGE_ID";
|
|
76
76
|
export { NEW_BROADCAST_MESSAGE_ID };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Enum defining supported Scratch block themes.
|
|
80
|
+
* Scratch block themes can customize the shape of blocks independently of their color.
|
|
81
|
+
*/
|
|
82
|
+
export enum ScratchBlocksTheme {
|
|
83
|
+
CLASSIC = "classic",
|
|
84
|
+
CAT_BLOCKS = "catblocks",
|
|
85
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import "./blocks/sound";
|
|
|
23
23
|
import * as scratchBlocksUtils from "./scratch_blocks_utils";
|
|
24
24
|
import * as ScratchVariables from "./variables";
|
|
25
25
|
import "./css";
|
|
26
|
+
import "./renderer/cat/renderer";
|
|
26
27
|
import "./renderer/renderer";
|
|
27
28
|
import * as contextMenuItems from "./context_menu_items";
|
|
28
29
|
import {
|
|
@@ -64,6 +65,7 @@ import { registerRecyclableBlockFlyoutInflater } from "./recyclable_block_flyout
|
|
|
64
65
|
import { registerScratchBlockPaster } from "./scratch_block_paster";
|
|
65
66
|
import { registerStatusIndicatorLabelFlyoutInflater } from "./status_indicator_label_flyout_inflater";
|
|
66
67
|
import { registerScratchContinuousCategory } from "./scratch_continuous_category";
|
|
68
|
+
import { ScratchBlocksTheme } from "./constants";
|
|
67
69
|
|
|
68
70
|
export * from "blockly/core";
|
|
69
71
|
export * from "./block_reporting";
|
|
@@ -83,7 +85,22 @@ export {
|
|
|
83
85
|
} from "./status_indicator_label";
|
|
84
86
|
export * from "./xml";
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
interface ScratchBlocksOptions extends Blockly.BlocklyOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Scratch uses "theme" to talk about the shape of blocks. The Blockly concept of a theme affects CSS properties and
|
|
91
|
+
* aligns more closely with "color mode" in Scratch.
|
|
92
|
+
*/
|
|
93
|
+
scratchTheme?: ScratchBlocksTheme;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sanitizeTheme(theme?: ScratchBlocksTheme) {
|
|
97
|
+
if (theme === ScratchBlocksTheme.CAT_BLOCKS) {
|
|
98
|
+
return theme;
|
|
99
|
+
}
|
|
100
|
+
return ScratchBlocksTheme.CLASSIC;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function inject(container: Element, options: ScratchBlocksOptions) {
|
|
87
104
|
registerScratchFieldAngle();
|
|
88
105
|
registerFieldColourSlider();
|
|
89
106
|
registerScratchFieldDropdown();
|
|
@@ -99,8 +116,10 @@ export function inject(container: Element, options: Blockly.BlocklyOptions) {
|
|
|
99
116
|
registerStatusIndicatorLabelFlyoutInflater();
|
|
100
117
|
registerScratchContinuousCategory();
|
|
101
118
|
|
|
119
|
+
const scratchTheme = sanitizeTheme(options.scratchTheme);
|
|
120
|
+
|
|
102
121
|
Object.assign(options, {
|
|
103
|
-
renderer:
|
|
122
|
+
renderer: `scratch_${scratchTheme}`,
|
|
104
123
|
plugins: {
|
|
105
124
|
toolbox: ScratchContinuousToolbox,
|
|
106
125
|
flyoutsVerticalToolbox: CheckableContinuousFlyout,
|
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as Blockly from "blockly/core";
|
|
8
|
+
import { ConstantProvider } from "./constants";
|
|
8
9
|
|
|
9
10
|
export class BowlerHat extends Blockly.blockRendering.Hat {
|
|
10
|
-
constructor(constants:
|
|
11
|
+
constructor(constants: ConstantProvider) {
|
|
11
12
|
super(constants);
|
|
12
13
|
// Calculated dynamically by computeBounds_().
|
|
13
14
|
this.width = 0;
|
|
14
|
-
this.height =
|
|
15
|
+
this.height = constants.BOWLER_HAT_HEIGHT;
|
|
15
16
|
this.ascenderHeight = this.height;
|
|
16
17
|
}
|
|
17
18
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Scratch Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Blockly from "blockly/core";
|
|
8
|
+
|
|
9
|
+
import { type ConstantProvider, type CatPathState, PathCapType, PathEarState } from "./constants";
|
|
10
|
+
import { type CatScratchRenderer } from "./renderer";
|
|
11
|
+
import { type RenderInfo } from "./render_info";
|
|
12
|
+
|
|
13
|
+
const Svg = Blockly.utils.Svg;
|
|
14
|
+
|
|
15
|
+
enum FacePart {
|
|
16
|
+
MOUTH,
|
|
17
|
+
EYE_1_OPEN,
|
|
18
|
+
EYE_2_OPEN,
|
|
19
|
+
EYE_1_CLOSED,
|
|
20
|
+
EYE_2_CLOSED,
|
|
21
|
+
EAR_1_INSIDE,
|
|
22
|
+
EAR_2_INSIDE,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const setVisibility = (element: SVGElement, visible: boolean) => {
|
|
26
|
+
if (visible) {
|
|
27
|
+
element.style.removeProperty("visibility");
|
|
28
|
+
} else {
|
|
29
|
+
element.style.setProperty("visibility", "hidden");
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Manages the SVG elements for the cat face.
|
|
35
|
+
* This class holds the persistent SVG elements and manages events (blinking, etc.)
|
|
36
|
+
* Owned by the PathObject with similar lifetime.
|
|
37
|
+
*/
|
|
38
|
+
export class CatFace {
|
|
39
|
+
faceGroup_: SVGElement;
|
|
40
|
+
parts_ = {} as Record<FacePart, SVGElement>;
|
|
41
|
+
pathEarState: CatPathState;
|
|
42
|
+
constants_: ConstantProvider;
|
|
43
|
+
renderer_: CatScratchRenderer;
|
|
44
|
+
block_: Blockly.BlockSvg;
|
|
45
|
+
|
|
46
|
+
constructor(info: RenderInfo) {
|
|
47
|
+
this.constants_ = info.constants_;
|
|
48
|
+
this.renderer_ = info.renderer_;
|
|
49
|
+
this.block_ = info.block_;
|
|
50
|
+
this.pathEarState = {
|
|
51
|
+
capType: info.isBowlerHatBlock() ? PathCapType.BOWLER : PathCapType.CAP,
|
|
52
|
+
ear1State: PathEarState.UP,
|
|
53
|
+
ear2State: PathEarState.UP,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initializes the face SVG elements if they haven't been created yet.
|
|
59
|
+
*/
|
|
60
|
+
init(parent: SVGElement) {
|
|
61
|
+
if (this.faceGroup_) return;
|
|
62
|
+
this.buildFaceGeometry_(parent);
|
|
63
|
+
this.setupBlinking_();
|
|
64
|
+
this.setupEarFlicks_();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Updates the transform of the entire face group.
|
|
69
|
+
*/
|
|
70
|
+
setTransform(transform: string) {
|
|
71
|
+
if (this.faceGroup_) {
|
|
72
|
+
this.faceGroup_.setAttribute("transform", transform);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private setupBlinking_() {
|
|
77
|
+
const blinkDuration = 100;
|
|
78
|
+
let ignoreBlink = false;
|
|
79
|
+
|
|
80
|
+
// TODO: Would it be better to use CSS for this?
|
|
81
|
+
Blockly.browserEvents.bind(this.block_.pathObject.svgPath, "mouseenter", this, () => {
|
|
82
|
+
if (ignoreBlink) return;
|
|
83
|
+
ignoreBlink = true;
|
|
84
|
+
setVisibility(this.parts_[FacePart.EYE_1_OPEN], false);
|
|
85
|
+
setVisibility(this.parts_[FacePart.EYE_2_OPEN], false);
|
|
86
|
+
setVisibility(this.parts_[FacePart.EYE_1_CLOSED], true);
|
|
87
|
+
setVisibility(this.parts_[FacePart.EYE_2_CLOSED], true);
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
setVisibility(this.parts_[FacePart.EYE_1_OPEN], true);
|
|
90
|
+
setVisibility(this.parts_[FacePart.EYE_2_OPEN], true);
|
|
91
|
+
setVisibility(this.parts_[FacePart.EYE_1_CLOSED], false);
|
|
92
|
+
setVisibility(this.parts_[FacePart.EYE_2_CLOSED], false);
|
|
93
|
+
}, blinkDuration);
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
ignoreBlink = false;
|
|
96
|
+
}, 2 * blinkDuration);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Asks the renderer to re-render the block at a time when it normally wouldn't.
|
|
102
|
+
* Necessary if the path geometry has changed (ear flicks).
|
|
103
|
+
* Not necessary for face changes (blinking).
|
|
104
|
+
*/
|
|
105
|
+
private triggerRedraw() {
|
|
106
|
+
this.renderer_.render(this.block_);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private setupEarFlicks_() {
|
|
110
|
+
const flickDuration = 50;
|
|
111
|
+
let ignoreFlick1 = false;
|
|
112
|
+
let ignoreFlick2 = false;
|
|
113
|
+
|
|
114
|
+
Blockly.browserEvents.bind(this.parts_[FacePart.EAR_1_INSIDE], "mouseenter", this, () => {
|
|
115
|
+
if (ignoreFlick1) return;
|
|
116
|
+
ignoreFlick1 = true;
|
|
117
|
+
setVisibility(this.parts_[FacePart.EAR_1_INSIDE], false);
|
|
118
|
+
this.pathEarState.ear1State = PathEarState.DOWN;
|
|
119
|
+
this.triggerRedraw();
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
setVisibility(this.parts_[FacePart.EAR_1_INSIDE], true);
|
|
122
|
+
this.pathEarState.ear1State = PathEarState.UP;
|
|
123
|
+
this.triggerRedraw();
|
|
124
|
+
}, flickDuration);
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
ignoreFlick1 = false;
|
|
127
|
+
}, 2 * flickDuration);
|
|
128
|
+
});
|
|
129
|
+
Blockly.browserEvents.bind(this.parts_[FacePart.EAR_2_INSIDE], "mouseenter", this, () => {
|
|
130
|
+
if (ignoreFlick2) return;
|
|
131
|
+
ignoreFlick2 = true;
|
|
132
|
+
setVisibility(this.parts_[FacePart.EAR_2_INSIDE], false);
|
|
133
|
+
this.pathEarState.ear2State = PathEarState.DOWN;
|
|
134
|
+
this.triggerRedraw();
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
setVisibility(this.parts_[FacePart.EAR_2_INSIDE], true);
|
|
137
|
+
this.pathEarState.ear2State = PathEarState.UP;
|
|
138
|
+
this.triggerRedraw();
|
|
139
|
+
}, flickDuration);
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
ignoreFlick2 = false;
|
|
142
|
+
}, 2 * flickDuration);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private buildFaceGeometry_(parent: SVGElement) {
|
|
147
|
+
const face = Blockly.utils.dom.createSvgElement(
|
|
148
|
+
Svg.G,
|
|
149
|
+
{
|
|
150
|
+
fill: "#000000",
|
|
151
|
+
// transform set in setTransform()
|
|
152
|
+
},
|
|
153
|
+
parent
|
|
154
|
+
);
|
|
155
|
+
this.faceGroup_ = face;
|
|
156
|
+
|
|
157
|
+
this.parts_[FacePart.MOUTH] = Blockly.utils.dom.createSvgElement(
|
|
158
|
+
Svg.PATH,
|
|
159
|
+
{
|
|
160
|
+
"fill-opacity": this.constants_.FACE_OPACITY,
|
|
161
|
+
d: this.constants_.MOUTH_PATH,
|
|
162
|
+
},
|
|
163
|
+
face
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
this.parts_[FacePart.EAR_1_INSIDE] = Blockly.utils.dom.createSvgElement(
|
|
167
|
+
Svg.PATH,
|
|
168
|
+
{
|
|
169
|
+
fill: this.constants_.EAR_INSIDE_COLOR,
|
|
170
|
+
d: this.constants_.EAR_1_INSIDE_PATH,
|
|
171
|
+
},
|
|
172
|
+
face
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
this.parts_[FacePart.EAR_2_INSIDE] = Blockly.utils.dom.createSvgElement(
|
|
176
|
+
Svg.PATH,
|
|
177
|
+
{
|
|
178
|
+
fill: this.constants_.EAR_INSIDE_COLOR,
|
|
179
|
+
d: this.constants_.EAR_2_INSIDE_PATH,
|
|
180
|
+
},
|
|
181
|
+
face
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
this.parts_[FacePart.EYE_1_OPEN] = Blockly.utils.dom.createSvgElement(
|
|
185
|
+
Svg.CIRCLE,
|
|
186
|
+
{
|
|
187
|
+
"fill-opacity": this.constants_.FACE_OPACITY,
|
|
188
|
+
cx: this.constants_.EYE_1_X,
|
|
189
|
+
cy: this.constants_.EYE_1_Y,
|
|
190
|
+
r: this.constants_.OPEN_EYE_RADIUS,
|
|
191
|
+
},
|
|
192
|
+
face
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
this.parts_[FacePart.EYE_1_CLOSED] = Blockly.utils.dom.createSvgElement(
|
|
196
|
+
Svg.PATH,
|
|
197
|
+
{
|
|
198
|
+
"fill-opacity": this.constants_.FACE_OPACITY,
|
|
199
|
+
d: this.constants_.CLOSED_EYE_1_PATH,
|
|
200
|
+
},
|
|
201
|
+
face
|
|
202
|
+
);
|
|
203
|
+
setVisibility(this.parts_[FacePart.EYE_1_CLOSED], false);
|
|
204
|
+
|
|
205
|
+
this.parts_[FacePart.EYE_2_OPEN] = Blockly.utils.dom.createSvgElement(
|
|
206
|
+
Svg.CIRCLE,
|
|
207
|
+
{
|
|
208
|
+
"fill-opacity": this.constants_.FACE_OPACITY,
|
|
209
|
+
cx: this.constants_.EYE_2_X,
|
|
210
|
+
cy: this.constants_.EYE_2_Y,
|
|
211
|
+
r: this.constants_.OPEN_EYE_RADIUS,
|
|
212
|
+
},
|
|
213
|
+
face
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
this.parts_[FacePart.EYE_2_CLOSED] = Blockly.utils.dom.createSvgElement(
|
|
217
|
+
Svg.PATH,
|
|
218
|
+
{
|
|
219
|
+
"fill-opacity": this.constants_.FACE_OPACITY,
|
|
220
|
+
d: this.constants_.CLOSED_EYE_2_PATH,
|
|
221
|
+
},
|
|
222
|
+
face
|
|
223
|
+
);
|
|
224
|
+
setVisibility(this.parts_[FacePart.EYE_2_CLOSED], false);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Scratch Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ConstantProvider as ClassicConstantProvider } from "../constants";
|
|
8
|
+
|
|
9
|
+
export enum PathCapType {
|
|
10
|
+
CAP = "CAP",
|
|
11
|
+
BOWLER = "BOWLER",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum PathEarState {
|
|
15
|
+
DOWN = "DOWN",
|
|
16
|
+
UP = "UP",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CatPathState {
|
|
20
|
+
capType: PathCapType;
|
|
21
|
+
ear1State: PathEarState; // Left ear in LTR, right in RTL
|
|
22
|
+
ear2State: PathEarState; // Right ear in LTR, left in RTL
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ConstantProvider extends ClassicConstantProvider {
|
|
26
|
+
START_HAT_HEIGHT = 31.5;
|
|
27
|
+
START_HAT_WIDTH = 96;
|
|
28
|
+
|
|
29
|
+
BOWLER_HAT_HEIGHT = 35;
|
|
30
|
+
|
|
31
|
+
FACE_OPACITY = 0.6;
|
|
32
|
+
|
|
33
|
+
EYE_1_X = 59.2;
|
|
34
|
+
EYE_1_Y = -3.3;
|
|
35
|
+
EYE_2_X = 29.1;
|
|
36
|
+
EYE_2_Y = -3.3;
|
|
37
|
+
OPEN_EYE_RADIUS = 3.4;
|
|
38
|
+
CLOSED_EYE_1_PATH =
|
|
39
|
+
"M25.2-1.1c0.1,0,0.2,0,0.2,0l8.3-2.1l-7-4.8" +
|
|
40
|
+
"c-0.5-0.3-1.1-0.2-1.4,0.3s-0.2,1.1,0.3,1.4L29-4.1l-4,1" +
|
|
41
|
+
"c-0.5,0.1-0.9,0.7-0.7,1.2C24.3-1.4,24.7-1.1,25.2-1.1z";
|
|
42
|
+
CLOSED_EYE_2_PATH =
|
|
43
|
+
"M62.4-1.1c-0.1,0-0.2,0-0.2,0l-8.3-2.1l7-4.8" +
|
|
44
|
+
"c0.5-0.3,1.1-0.2,1.4,0.3s0.2,1.1-0.3,1.4l-3.4,2.3l4,1" +
|
|
45
|
+
"c0.5,0.1,0.9,0.7,0.7,1.2C63.2-1.4,62.8-1.1,62.4-1.1z";
|
|
46
|
+
|
|
47
|
+
MOUTH_PATH =
|
|
48
|
+
"M45.6,0.1c-0.9,0-1.7-0.3-2.3-0.9" +
|
|
49
|
+
"c-0.6,0.6-1.3,0.9-2.2,0.9c-0.9,0-1.8-0.3-2.3-0.9c-1-1.1-1.1-2.6-1.1-2.8" +
|
|
50
|
+
"c0-0.5,0.5-1,1-1l0,0c0.6,0,1,0.5,1,1c0,0.4,0.1,1.7,1.4,1.7" +
|
|
51
|
+
"c0.5,0,0.7-0.2,0.8-0.3c0.3-0.3,0.4-1,0.4-1.3c0-0.1,0-0.1,0-0.2" +
|
|
52
|
+
"c0-0.5,0.5-1,1-1l0,0c0.5,0,1,0.4,1,1c0,0,0,0.1,0,0.2" +
|
|
53
|
+
"c0,0.3,0.1,0.9,0.4,1.2C44.8-2.2,45-2,45.5-2s0.7-0.2,0.8-0.3" +
|
|
54
|
+
"c0.3-0.4,0.4-1.1,0.3-1.3c0-0.5,0.4-1,0.9-1.1c0.5,0,1,0.4,1.1,0.9" +
|
|
55
|
+
"c0,0.2,0.1,1.8-0.8,2.8C47.5-0.4,46.8,0.1,45.6,0.1z";
|
|
56
|
+
|
|
57
|
+
EAR_INSIDE_COLOR = "#FFD5E6";
|
|
58
|
+
EAR_1_INSIDE_PATH =
|
|
59
|
+
"M22.4-15.6c-1.7-4.2-4.5-9.1-5.8-8.5" +
|
|
60
|
+
"c-1.6,0.8-5.4,7.9-5,15.4c0,0.6,0.7,0.7,1.1,0.5c3-1.6,6.4-2.8,8.6-3.6" +
|
|
61
|
+
"C22.8-12.3,23.2-13.7,22.4-15.6z";
|
|
62
|
+
EAR_2_INSIDE_PATH =
|
|
63
|
+
"M73.1-15.6c1.7-4.2,4.5-9.1,5.8-8.5" +
|
|
64
|
+
"c1.6,0.8,5.4,7.9,5,15.4c0,0.6-0.7,0.7-1.1,0.5c-3-1.6-6.4-2.8-8.6-3.6" +
|
|
65
|
+
"C72.8-12.3,72.4-13.7,73.1-15.6z";
|
|
66
|
+
|
|
67
|
+
CAP_START_PATH = "c2.6,-2.3 5.5,-4.3 8.5,-6.2";
|
|
68
|
+
CAP_MIDDLE_PATH = "c8.4,-1.3 17,-1.3 25.4,0";
|
|
69
|
+
CAP_END_PATH = "c3,1.8 5.9,3.9 8.5,6.1";
|
|
70
|
+
|
|
71
|
+
CAP_EAR_1_UP_PATH =
|
|
72
|
+
"c-1,-12.5 5.3,-23.3 8.4,-24.8" + "c3.7,-1.8 16.5,13.1 18.4,15.4";
|
|
73
|
+
CAP_EAR_2_UP_PATH =
|
|
74
|
+
"c1.9,-2.3 14.7,-17.2 18.4,-15.4" + "c3.1,1.5 9.4,12.3 8.4,24.8";
|
|
75
|
+
CAP_EAR_1_DOWN_PATH =
|
|
76
|
+
"c-5.8,-4.8 -8,-18 -4.9,-19.5" + "c3.7,-1.8 24.5,11.1 31.7,10.1";
|
|
77
|
+
CAP_EAR_2_DOWN_PATH =
|
|
78
|
+
"c7.2,1 28,-11.9 31.7,-10.1" + "c3.1,1.5 0.9,14.7 -4.9,19.5";
|
|
79
|
+
|
|
80
|
+
BOWLER_START_PATH = ""; // opening curve depends on whether ear 1 is up or down
|
|
81
|
+
BOWLER_MIDDLE_PATH = "h33";
|
|
82
|
+
BOWLER_END_PATH = "a 20,20 0 0,1 20,20";
|
|
83
|
+
BOWLER_EAR_1_UP_PATH =
|
|
84
|
+
"c0,-7.1 3.7,-13.3 9.3,-16.9" +
|
|
85
|
+
"c1.7,-7.5 5.4,-13.2 7.6,-14.2" +
|
|
86
|
+
"c2.6,-1.3 10,6 14.6,11.1";
|
|
87
|
+
BOWLER_EAR_2_UP_PATH =
|
|
88
|
+
"c4.6,-5.1 11.9,-12.4 14.6,-11.1" +
|
|
89
|
+
"c1.9,0.9 4.9,5.2 6.8,11.1" +
|
|
90
|
+
"h7.8";
|
|
91
|
+
BOWLER_EAR_1_DOWN_PATH =
|
|
92
|
+
"c0,-4.6 1.6,-8.9 4.3,-12.3" +
|
|
93
|
+
"c-2.4,-5.6 -2.9,-12.4 -0.7,-13.4" +
|
|
94
|
+
"c2.1,-1 9.6,2.6 17,5.8" +
|
|
95
|
+
"h10.9";
|
|
96
|
+
BOWLER_EAR_2_DOWN_PATH =
|
|
97
|
+
"h11" +
|
|
98
|
+
"c7.4,-3.2 14.8,-6.8 16.9,-5.8" +
|
|
99
|
+
"c1.2,0.6 1.6,2.9 1.3,5.8";
|
|
100
|
+
|
|
101
|
+
// This number was determined experimentally:
|
|
102
|
+
// - The 17 came from zooming in on a "define" block and iterating to get a near-vertical edge.
|
|
103
|
+
// - The .7 came from measuring the width of the other parts of the SVG path.
|
|
104
|
+
BOWLER_WIDTH_MAGIC = 17.7;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Make the starting portion of a block's hat.
|
|
108
|
+
* The return value will be stored as START_HAT.
|
|
109
|
+
* In the case of cat blocks, this is just a placeholder for sizing.
|
|
110
|
+
*/
|
|
111
|
+
override makeStartHat() {
|
|
112
|
+
return {
|
|
113
|
+
height: this.START_HAT_HEIGHT,
|
|
114
|
+
width: this.START_HAT_WIDTH,
|
|
115
|
+
path: this.makeCatPath(0, {
|
|
116
|
+
capType: PathCapType.CAP,
|
|
117
|
+
ear1State: PathEarState.UP,
|
|
118
|
+
ear2State: PathEarState.UP,
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
makeCatPath(width: number, state: CatPathState) {
|
|
124
|
+
const pathStart = this[`${state.capType}_START_PATH`];
|
|
125
|
+
const pathEar1 =
|
|
126
|
+
this[`${state.capType}_EAR_1_${state.ear1State}_PATH`];
|
|
127
|
+
const pathMiddle = this[`${state.capType}_MIDDLE_PATH`];
|
|
128
|
+
const pathEar2 =
|
|
129
|
+
this[`${state.capType}_EAR_2_${state.ear2State}_PATH`];
|
|
130
|
+
const spacer = (state.capType === PathCapType.BOWLER)
|
|
131
|
+
? `l ${width - this.START_HAT_WIDTH - this.BOWLER_WIDTH_MAGIC} 0`
|
|
132
|
+
: ""; // caps don't need an internal spacer like bowlers do
|
|
133
|
+
const pathEnd = this[`${state.capType}_END_PATH`];
|
|
134
|
+
return `${pathStart}${pathEar1}${pathMiddle}${pathEar2}${spacer}${pathEnd}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Scratch Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Drawer as ClassicDrawer } from "../drawer";
|
|
8
|
+
|
|
9
|
+
import { type ConstantProvider } from "./constants";
|
|
10
|
+
import { type PathObject } from "./path_object";
|
|
11
|
+
import { type RenderInfo } from "./render_info";
|
|
12
|
+
import { CatFace } from "./cat_face";
|
|
13
|
+
|
|
14
|
+
export class Drawer extends ClassicDrawer {
|
|
15
|
+
constants_: ConstantProvider;
|
|
16
|
+
info_: RenderInfo;
|
|
17
|
+
|
|
18
|
+
override draw() {
|
|
19
|
+
// Make sure the face exists if we need it.
|
|
20
|
+
if (this.block_.hat) {
|
|
21
|
+
const pathObject = this.block_.pathObject as PathObject;
|
|
22
|
+
|
|
23
|
+
if (!pathObject.catFace) {
|
|
24
|
+
// Initialize the persistent face view.
|
|
25
|
+
// Be aware of lifetimes:
|
|
26
|
+
// Drawer and RenderInfo only exist during `Renderer.render(block)`.
|
|
27
|
+
// Block, PathObject, and CatFace last for the lifetime of the block.
|
|
28
|
+
// ConstantsProvider and the Renderer last for the lifetime of the workspace.
|
|
29
|
+
pathObject.catFace = new CatFace(this.info_);
|
|
30
|
+
pathObject.catFace.init(this.block_.getSvgRoot());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
super.draw();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override drawInternals_() {
|
|
38
|
+
super.drawInternals_();
|
|
39
|
+
|
|
40
|
+
const pathObject = this.block_.pathObject as PathObject;
|
|
41
|
+
const catFace = pathObject.catFace;
|
|
42
|
+
if (catFace) {
|
|
43
|
+
// Update the transform for the whole group
|
|
44
|
+
const transformParts: string[] = [];
|
|
45
|
+
if (this.info_.RTL) {
|
|
46
|
+
transformParts.push("scale(-1 1)");
|
|
47
|
+
}
|
|
48
|
+
transformParts.push(`translate(0, ${this.info_.startY})`);
|
|
49
|
+
catFace.setTransform(transformParts.join(" "));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override makeReplacementTop_() {
|
|
54
|
+
if (!this.block_.hat) {
|
|
55
|
+
return super.makeReplacementTop_();
|
|
56
|
+
}
|
|
57
|
+
const pathObject = this.block_.pathObject as PathObject;
|
|
58
|
+
return this.constants_.makeCatPath(
|
|
59
|
+
this.info_.width,
|
|
60
|
+
pathObject.catFace.pathEarState
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Scratch Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PathObject as ClassicPathObject } from "../path_object";
|
|
8
|
+
import { type CatFace } from "./cat_face";
|
|
9
|
+
|
|
10
|
+
export class PathObject extends ClassicPathObject {
|
|
11
|
+
/**
|
|
12
|
+
* The face view for this block.
|
|
13
|
+
* Only valid if this block has a hat and therefore should have a face.
|
|
14
|
+
*/
|
|
15
|
+
catFace?: CatFace;
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Scratch Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RenderInfo as ClassicRenderInfo } from "../render_info";
|
|
8
|
+
|
|
9
|
+
import { ConstantProvider } from "./constants";
|
|
10
|
+
import { CatScratchRenderer } from "./renderer";
|
|
11
|
+
|
|
12
|
+
export class RenderInfo extends ClassicRenderInfo {
|
|
13
|
+
override constants_: ConstantProvider;
|
|
14
|
+
override renderer_: CatScratchRenderer;
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Scratch Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Blockly from "blockly/core";
|
|
8
|
+
|
|
9
|
+
import { ScratchRenderer } from "../renderer";
|
|
10
|
+
|
|
11
|
+
import { ConstantProvider } from "./constants";
|
|
12
|
+
import { Drawer } from "./drawer";
|
|
13
|
+
import { RenderInfo } from "./render_info";
|
|
14
|
+
import { PathObject } from "./path_object";
|
|
15
|
+
|
|
16
|
+
export class CatScratchRenderer extends ScratchRenderer {
|
|
17
|
+
override makeConstants_() {
|
|
18
|
+
return new ConstantProvider();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override makeDrawer_(block: Blockly.BlockSvg, info: RenderInfo) {
|
|
22
|
+
return new Drawer(block, info);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override makeRenderInfo_(block: Blockly.BlockSvg): RenderInfo {
|
|
26
|
+
return new RenderInfo(this, block);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override makePathObject(root: SVGElement, style: Blockly.Theme.BlockStyle): PathObject {
|
|
30
|
+
return new PathObject(root, style, this.getConstants());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Blockly.blockRendering.register("scratch_catblocks", CatScratchRenderer);
|
|
@@ -10,6 +10,8 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider {
|
|
|
10
10
|
REPLACEMENT_GLOW_COLOUR = "#ffffff";
|
|
11
11
|
SELECTED_GLOW_COLOUR = "#ffffff";
|
|
12
12
|
|
|
13
|
+
BOWLER_HAT_HEIGHT = 20;
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* Sets the visual theme used to render the workspace.
|
|
15
17
|
* This method also synthesizes a "selected" theme, used to color blocks with
|
|
@@ -20,7 +22,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider {
|
|
|
20
22
|
*
|
|
21
23
|
* @param theme The new theme to apply.
|
|
22
24
|
*/
|
|
23
|
-
setTheme(theme: Blockly.Theme) {
|
|
25
|
+
override setTheme(theme: Blockly.Theme) {
|
|
24
26
|
const root = document.querySelector(":root") as HTMLElement;
|
|
25
27
|
for (const [key, colour] of Object.entries(theme.blockStyles)) {
|
|
26
28
|
if (typeof colour !== "object") {
|
|
@@ -52,8 +54,20 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider {
|
|
|
52
54
|
super.setTheme(theme);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
createDom(svg: SVGElement, tagName: string, selector: string) {
|
|
57
|
+
override createDom(svg: SVGElement, tagName: string, selector: string) {
|
|
56
58
|
super.createDom(svg, tagName, selector);
|
|
57
59
|
this.selectedGlowFilterId = "";
|
|
58
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a bowler hat path string for a specific block.
|
|
64
|
+
* @param width the `info_.width` of the block.
|
|
65
|
+
* @returns The SVG path string for the bowler hat.
|
|
66
|
+
*/
|
|
67
|
+
makeBowlerHatPath(width: number): string {
|
|
68
|
+
const bowlerHatPath = `a20,20 0 0,1 20,-20 l ${
|
|
69
|
+
width - 40
|
|
70
|
+
} 0 a20,20 0 0,1 20,20`;
|
|
71
|
+
return bowlerHatPath;
|
|
72
|
+
}
|
|
59
73
|
}
|