Skip to content

Commit 44643d6

Browse files
committed
Merge branch 'develop' into spork
2 parents 1ef3507 + 86133f1 commit 44643d6

File tree

11 files changed

+473
-11
lines changed

11 files changed

+473
-11
lines changed

src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,12 @@ export { OUTPUT_SHAPE_ROUND };
7474
*/
7575
const NEW_BROADCAST_MESSAGE_ID = "NEW_BROADCAST_MESSAGE_ID";
7676
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+
}

src/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import "./blocks/sound";
2323
import * as scratchBlocksUtils from "./scratch_blocks_utils";
2424
import * as ScratchVariables from "./variables";
2525
import "./css";
26+
import "./renderer/cat/renderer";
2627
import "./renderer/renderer";
2728
import * as contextMenuItems from "./context_menu_items";
2829
import {
@@ -64,6 +65,7 @@ import { registerRecyclableBlockFlyoutInflater } from "./recyclable_block_flyout
6465
import { registerScratchBlockPaster } from "./scratch_block_paster";
6566
import { registerStatusIndicatorLabelFlyoutInflater } from "./status_indicator_label_flyout_inflater";
6667
import { registerScratchContinuousCategory } from "./scratch_continuous_category";
68+
import { ScratchBlocksTheme } from "./constants";
6769

6870
export * from "blockly/core";
6971
export * from "./block_reporting";
@@ -83,7 +85,18 @@ export {
8385
} from "./status_indicator_label";
8486
export * from "./xml";
8587

86-
export function inject(container: Element, options: Blockly.BlocklyOptions) {
88+
interface ScratchBlocksOptions extends Blockly.BlocklyOptions {
89+
theme?: ScratchBlocksTheme;
90+
}
91+
92+
function sanitizeTheme(theme?: ScratchBlocksTheme) {
93+
if (theme === ScratchBlocksTheme.CAT_BLOCKS) {
94+
return theme;
95+
}
96+
return ScratchBlocksTheme.CLASSIC;
97+
}
98+
99+
export function inject(container: Element, options: ScratchBlocksOptions) {
87100
registerScratchFieldAngle();
88101
registerFieldColourSlider();
89102
registerScratchFieldDropdown();
@@ -99,8 +112,10 @@ export function inject(container: Element, options: Blockly.BlocklyOptions) {
99112
registerStatusIndicatorLabelFlyoutInflater();
100113
registerScratchContinuousCategory();
101114

115+
const theme = sanitizeTheme(options.theme);
116+
102117
Object.assign(options, {
103-
renderer: "scratch",
118+
renderer: `scratch_${theme}`,
104119
plugins: {
105120
toolbox: ScratchContinuousToolbox,
106121
flyoutsVerticalToolbox: CheckableContinuousFlyout,

src/renderer/bowler_hat.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
*/
66

77
import * as Blockly from "blockly/core";
8+
import { ConstantProvider } from "./constants";
89

910
export class BowlerHat extends Blockly.blockRendering.Hat {
10-
constructor(constants: Blockly.blockRendering.ConstantProvider) {
11+
constructor(constants: ConstantProvider) {
1112
super(constants);
1213
// Calculated dynamically by computeBounds_().
1314
this.width = 0;
14-
this.height = 20;
15+
this.height = constants.BOWLER_HAT_HEIGHT;
1516
this.ascenderHeight = this.height;
1617
}
1718
}

src/renderer/cat/cat_block_svg.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Scratch Foundation
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from "blockly/core";
8+
9+
// Hack to track whether we've already added a face to this block.
10+
// If the face looks too dark/opaque, there may be multiple faces being added.
11+
// TODO: is there a better place to put this flag, or a good way to add-or-update a specific BlockSvg child?
12+
// The face can't just be part of the inline or outline paths because it has different attributes.
13+
export interface CatBlockSvg extends Blockly.BlockSvg {
14+
hasFace: boolean;
15+
}

src/renderer/cat/constants.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @license
3+
* Copyright 2025 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;
22+
ear2State: PathEarState;
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+
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+
: ""; // allow cap logic to finish the path
133+
const pathEnd = this[`${state.capType}_END_PATH`];
134+
return `${pathStart}${pathEar1}${pathMiddle}${pathEar2}${spacer}${pathEnd}`;
135+
}
136+
}

0 commit comments

Comments
 (0)