Code
(function(Scratch) {
'use strict';
class VisualRaycastExtension {
constructor() {
this.rayData = [];
this.maxRays = 360;
this.disabledSprites = new Set();
this.rayColor = '#FF0000';
this.rayThickness = 2;
this.showRays = true;
this.showHitPoints = true;
this.hitPointSize = 5;
this.rayOpacity = 80;
this.penExtension = null;
// Load pen extension automatically
this.loadPenExtension();
}
loadPenExtension() {
// Try to load pen extension if not already loaded
if (typeof Scratch !== 'undefined' && Scratch.extensions) {
try {
// Check if pen extension is already loaded
if (!Scratch.vm.runtime.ext_pen) {
// Try to load the built-in pen extension
const PenExtension = Scratch.extensions._extensions.pen;
if (PenExtension) {
Scratch.vm.runtime.ext_pen = new PenExtension(Scratch.vm.runtime);
} else {
// Fallback: try to register pen extension
import('scratch-vm/src/extensions/scratch3_pen/index.js').then(penModule => {
Scratch.vm.runtime.ext_pen = new penModule.default(Scratch.vm.runtime);
}).catch(() => {
console.warn('Could not load pen extension automatically');
});
}
}
} catch (e) {
console.warn('Could not auto-load pen extension:', e);
}
}
}
getInfo() {
return {
id: 'visualraycast',
name: 'Visual Raycast',
color1: '#FF6B35',
color2: '#F7931E',
color3: '#FFD23F',
// Depend on pen extension
docsURI: 'https://docs.scratch.mit.edu/en/latest/extensions.html',
blocks: [
// Main raycasting blocks
{
opcode: 'castRay',
blockType: Scratch.BlockType.REPORTER,
text: 'cast ray from X [X] Y [Y] direction [DIRECTION] distance [DISTANCE]',
arguments: {
X: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 0
},
Y: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 0
},
DIRECTION: {
type: Scratch.ArgumentType.ANGLE,
defaultValue: 90
},
DISTANCE: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 200
}
}
},
{
opcode: 'castMultipleRays',
blockType: Scratch.BlockType.COMMAND,
text: 'cast [COUNT] rays from X [X] Y [Y] spread [SPREAD] degrees distance [DISTANCE]',
arguments: {
COUNT: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 8
},
X: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 0
},
Y: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 0
},
SPREAD: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 360
},
DISTANCE: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 200
}
}
},
'---',
// Visual settings blocks
{
opcode: 'setRayColor',
blockType: Scratch.BlockType.COMMAND,
text: 'set ray color to [COLOR]',
arguments: {
COLOR: {
type: Scratch.ArgumentType.COLOR,
defaultValue: '#FF0000'
}
}
},
{
opcode: 'setRayThickness',
blockType: Scratch.BlockType.COMMAND,
text: 'set ray thickness to [THICKNESS]',
arguments: {
THICKNESS: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 2
}
}
},
{
opcode: 'setRayOpacity',
blockType: Scratch.BlockType.COMMAND,
text: 'set ray opacity to [OPACITY] %',
arguments: {
OPACITY: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 80
}
}
},
{
opcode: 'toggleRayVisibility',
blockType: Scratch.BlockType.COMMAND,
text: 'set rays visible [VISIBLE]',
arguments: {
VISIBLE: {
type: Scratch.ArgumentType.STRING,
menu: 'visibilityMenu'
}
}
},
{
opcode: 'toggleHitPoints',
blockType: Scratch.BlockType.COMMAND,
text: 'set hit points visible [VISIBLE]',
arguments: {
VISIBLE: {
type: Scratch.ArgumentType.STRING,
menu: 'visibilityMenu'
}
}
},
{
opcode: 'setHitPointSize',
blockType: Scratch.BlockType.COMMAND,
text: 'set hit point size to [SIZE]',
arguments: {
SIZE: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 5
}
}
},
'---',
// Data retrieval blocks
{
opcode: 'getHitSpriteX',
blockType: Scratch.BlockType.REPORTER,
text: 'hit sprite X at ray [INDEX]',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getHitSpriteY',
blockType: Scratch.BlockType.REPORTER,
text: 'hit sprite Y at ray [INDEX]',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getHitSpriteSize',
blockType: Scratch.BlockType.REPORTER,
text: 'hit sprite size at ray [INDEX]',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getHitSpriteName',
blockType: Scratch.BlockType.REPORTER,
text: 'hit sprite name at ray [INDEX]',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getAllHitSprites',
blockType: Scratch.BlockType.REPORTER,
text: 'all hit sprites at ray [INDEX]',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getHitDistance',
blockType: Scratch.BlockType.REPORTER,
text: 'hit distance at ray [INDEX]',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
'---',
// Utility blocks
{
opcode: 'getRayCount',
blockType: Scratch.BlockType.REPORTER,
text: 'number of active rays'
},
{
opcode: 'rayHitSprite',
blockType: Scratch.BlockType.BOOLEAN,
text: 'ray [INDEX] hit a sprite?',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getRayX',
blockType: Scratch.BlockType.REPORTER,
text: 'ray [INDEX] end X',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'getRayY',
blockType: Scratch.BlockType.REPORTER,
text: 'ray [INDEX] end Y',
arguments: {
INDEX: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
}
},
'---',
{
opcode: 'disableThisSprite',
blockType: Scratch.BlockType.COMMAND,
text: 'disable this sprite from ray detection'
},
{
opcode: 'enableThisSprite',
blockType: Scratch.BlockType.COMMAND,
text: 'enable this sprite for ray detection'
},
{
opcode: 'clearRays',
blockType: Scratch.BlockType.COMMAND,
text: 'clear all rays'
},
{
opcode: 'clearPenLayer',
blockType: Scratch.BlockType.COMMAND,
text: 'clear ray visuals'
},
{
opcode: 'ensurePenExtension',
blockType: Scratch.BlockType.COMMAND,
text: 'ensure pen extension is loaded'
}
],
menus: {
visibilityMenu: {
acceptReporters: true,
items: ['true', 'false']
}
}
};
}
// Visual setting blocks
setRayColor(args) {
this.rayColor = args.COLOR;
}
setRayThickness(args) {
this.rayThickness = Math.max(1, Scratch.Cast.toNumber(args.THICKNESS));
}
setRayOpacity(args) {
this.rayOpacity = Math.max(0, Math.min(100, Scratch.Cast.toNumber(args.OPACITY)));
}
toggleRayVisibility(args) {
this.showRays = args.VISIBLE === 'true';
}
toggleHitPoints(args) {
this.showHitPoints = args.VISIBLE === 'true';
}
setHitPointSize(args) {
this.hitPointSize = Math.max(1, Scratch.Cast.toNumber(args.SIZE));
}
clearPenLayer(args, util) {
const runtime = util.runtime;
if (!runtime.ext_pen) return;
try {
const penSkinId = runtime.ext_pen._penSkinId;
if (penSkinId && runtime.renderer) {
if (runtime.renderer.penClear) {
runtime.renderer.penClear(penSkinId);
} else if (runtime.ext_pen._penClear) {
runtime.ext_pen._penClear();
}
}
} catch (e) {
console.warn('Could not clear pen layer:', e);
}
}
// Helper function to draw lines using pen
drawLine(startX, startY, endX, endY, util) {
const runtime = util.runtime;
// Ensure pen extension is available
if (!runtime.ext_pen) {
console.warn('Pen extension not available');
return;
}
try {
// Method 1: Use pen extension's drawing methods
const target = util.target;
const oldX = target.x;
const oldY = target.y;
const oldPenDown = target.getPenState ? target.getPenState().penDown : false;
// Get current pen state
const penState = runtime.ext_pen._getPenState ? runtime.ext_pen._getPenState(target) : null;
if (penState) {
// Save old pen properties
const oldColor = penState.color;
const oldSize = penState.penAttributes.diameter;
// Set ray color and size
const hex = this.rayColor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
penState.color = (r << 16) | (g << 8) | b;
penState.penAttributes.diameter = this.rayThickness;
penState.penAttributes.color4f = [r / 255, g / 255, b / 255, this.rayOpacity / 100];
// Move to start position and put pen down
target.setXY(startX, startY);
runtime.ext_pen.penDown({}, util);
// Draw line to end position
target.setXY(endX, endY);
// Lift pen
runtime.ext_pen.penUp({}, util);
// Restore original position and pen state
target.setXY(oldX, oldY);
if (!oldPenDown) {
runtime.ext_pen.penUp({}, util);
}
// Restore pen properties
penState.color = oldColor;
penState.penAttributes.diameter = oldSize;
} else {
// Fallback method using direct renderer
this.drawLineDirect(startX, startY, endX, endY, util);
}
} catch (e) {
console.warn('Error drawing line:', e);
this.drawLineDirect(startX, startY, endX, endY, util);
}
}
// Direct drawing method as fallback
drawLineDirect(startX, startY, endX, endY, util) {
const runtime = util.runtime;
if (!runtime.ext_pen) return;
try {
const penSkinId = runtime.ext_pen._penSkinId;
if (!penSkinId || !runtime.renderer) return;
// Convert hex color to RGB
const hex = this.rayColor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
const a = this.rayOpacity / 100;
const penAttributes = {
color4f: [r, g, b, a],
diameter: this.rayThickness
};
// Convert Scratch coordinates (Y is flipped)
const scratchStartY = -startY;
const scratchEndY = -endY;
// Try different renderer methods
if (runtime.renderer.penLine) {
runtime.renderer.penLine(penSkinId, penAttributes, startX, scratchStartY, endX, scratchEndY);
} else if (runtime.renderer._penLine) {
runtime.renderer._penLine(penSkinId, penAttributes, startX, scratchStartY, endX, scratchEndY);
} else {
// Final fallback - try to find any pen drawing method
const renderer = runtime.renderer;
for (const method of ['penLine', '_penLine', 'drawLine']) {
if (typeof renderer[method] === 'function') {
renderer[method](penSkinId, penAttributes, startX, scratchStartY, endX, scratchEndY);
break;
}
}
}
} catch (e) {
console.warn('Direct line drawing failed:', e);
}
}
// Helper function to draw circles for hit points
drawCircle(centerX, centerY, radius, util) {
const runtime = util.runtime;
if (!runtime.ext_pen) return;
// Draw circle as multiple line segments
const segments = 12;
for (let i = 0; i < segments; i++) {
const angle1 = (i / segments) * 2 * Math.PI;
const angle2 = ((i + 1) / segments) * 2 * Math.PI;
const x1 = centerX + Math.cos(angle1) * radius;
const y1 = centerY + Math.sin(angle1) * radius;
const x2 = centerX + Math.cos(angle2) * radius;
const y2 = centerY + Math.sin(angle2) * radius;
this.drawLine(x1, y1, x2, y2, util);
}
}
castRay(args, util) {
const startX = Scratch.Cast.toNumber(args.X);
const startY = Scratch.Cast.toNumber(args.Y);
const direction = Scratch.Cast.toNumber(args.DIRECTION);
const maxDistance = Scratch.Cast.toNumber(args.DISTANCE);
const rayResult = this.performRaycast(startX, startY, direction, maxDistance, util);
this.rayData = [rayResult];
// Draw the ray visually
if (this.showRays) {
this.drawLine(startX, startY, rayResult.hitX, rayResult.hitY, util);
}
// Draw hit point if ray hit something
if (this.showHitPoints && rayResult.hit) {
this.drawCircle(rayResult.hitX, rayResult.hitY, this.hitPointSize, util);
}
if (rayResult.hitSprites && rayResult.hitSprites.length > 0) {
return JSON.stringify(rayResult.hitSprites);
} else {
return JSON.stringify([]);
}
}
castMultipleRays(args, util) {
const count = Math.min(Math.max(1, Scratch.Cast.toNumber(args.COUNT)), this.maxRays);
const startX = Scratch.Cast.toNumber(args.X);
const startY = Scratch.Cast.toNumber(args.Y);
const spread = Scratch.Cast.toNumber(args.SPREAD);
const maxDistance = Scratch.Cast.toNumber(args.DISTANCE);
this.rayData = [];
const angleStep = count > 1 ? spread / (count - 1) : 0;
const startAngle = -spread / 2;
for (let i = 0; i < count; i++) {
const direction = startAngle + (angleStep * i);
const rayResult = this.performRaycast(startX, startY, direction, maxDistance, util);
this.rayData.push(rayResult);
// Draw each ray visually
if (this.showRays) {
this.drawLine(startX, startY, rayResult.hitX, rayResult.hitY, util);
}
// Draw hit points
if (this.showHitPoints && rayResult.hit) {
this.drawCircle(rayResult.hitX, rayResult.hitY, this.hitPointSize, util);
}
}
}
performRaycast(startX, startY, direction, maxDistance, util) {
const runtime = util.runtime;
const angleRad = ((direction - 90) * Math.PI) / 180;
const deltaX = Math.cos(angleRad);
const deltaY = Math.sin(angleRad);
let hitSprites = [];
let closestHit = null;
let closestDistance = maxDistance;
const allSprites = runtime.targets.filter(target => {
return !target.isStage &&
target !== util.target &&
target.visible &&
!this.disabledSprites.has(target.getName());
});
// Collect all hits along the ray path
for (const target of allSprites) {
const hitResult = this.checkRayTargetIntersection(
startX, startY, deltaX, deltaY, maxDistance, target
);
if (hitResult) {
hitSprites.push({
name: target.getName(),
distance: hitResult.distance,
hitX: hitResult.hitX,
hitY: hitResult.hitY,
spriteX: target.x,
spriteY: target.y,
spriteSize: target.size
});
// Still track closest hit for compatibility
if (hitResult.distance < closestDistance) {
closestDistance = hitResult.distance;
closestHit = {
hit: true,
spriteName: target.getName(),
spriteX: target.x,
spriteY: target.y,
spriteSize: target.size,
hitX: hitResult.hitX,
hitY: hitResult.hitY,
distance: hitResult.distance
};
}
}
}
// Sort hits by distance (closest first)
hitSprites.sort((a, b) => a.distance - b.distance);
if (closestHit) {
return {
...closestHit,
hitSprites: hitSprites.map(hit => hit.name)
};
} else {
return {
hit: false,
spriteName: '',
spriteX: 0,
spriteY: 0,
spriteSize: 0,
hitX: startX + deltaX * maxDistance,
hitY: startY + deltaY * maxDistance,
distance: maxDistance,
hitSprites: []
};
}
}
checkRayTargetIntersection(startX, startY, deltaX, deltaY, maxDistance, target) {
const spriteX = target.x;
const spriteY = target.y;
const spriteSize = target.size / 100;
// Get sprite dimensions
let bounds = null;
try {
if (target.getBounds) {
bounds = target.getBounds();
} else if (target.drawable && target.drawable.getBounds) {
bounds = target.drawable.getBounds();
}
} catch (e) {
// Bounds not available
}
let width, height;
if (bounds && bounds.width && bounds.height) {
width = bounds.width;
height = bounds.height;
} else {
const costume = target.getCostume();
if (costume && costume.size) {
width = costume.size[0] * spriteSize;
height = costume.size[1] * spriteSize;
} else {
width = 60 * spriteSize;
height = 60 * spriteSize;
}
}
width = Math.max(width, 1);
height = Math.max(height, 1);
// Calculate sprite bounds
const left = spriteX - (width / 2);
const right = spriteX + (width / 2);
const bottom = spriteY - (height / 2);
const top = spriteY + (height / 2);
// Simple step-by-step ray marching to avoid teleporting
const stepSize = 1; // Small step size for accuracy
const steps = Math.ceil(maxDistance / stepSize);
for (let i = 1; i <= steps; i++) {
const distance = i * stepSize;
if (distance > maxDistance) break;
const checkX = startX + deltaX * distance;
const checkY = startY + deltaY * distance;
// Check if this point is inside the sprite bounds
if (checkX >= left && checkX <= right &&
checkY >= bottom && checkY <= top) {
return {
hitX: checkX,
hitY: checkY,
distance: distance
};
}
}
return null;
}
// Data retrieval methods (unchanged from original)
getHitSpriteX(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length && this.rayData[index].hit) {
return this.rayData[index].spriteX;
}
return 0;
}
getHitSpriteY(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length && this.rayData[index].hit) {
return this.rayData[index].spriteY;
}
return 0;
}
getHitSpriteSize(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length && this.rayData[index].hit) {
return this.rayData[index].spriteSize;
}
return 0;
}
getHitSpriteName(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length && this.rayData[index].hit) {
return this.rayData[index].spriteName;
}
return '';
}
getAllHitSprites(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length && this.rayData[index].hitSprites) {
return JSON.stringify(this.rayData[index].hitSprites);
}
return JSON.stringify([]);
}
getHitDistance(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length) {
return this.rayData[index].distance;
}
return 0;
}
getRayCount() {
return this.rayData.length;
}
rayHitSprite(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length) {
return this.rayData[index].hit;
}
return false;
}
getRayX(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length) {
return this.rayData[index].hitX;
}
return 0;
}
getRayY(args) {
const index = Math.floor(Scratch.Cast.toNumber(args.INDEX)) - 1;
if (index >= 0 && index < this.rayData.length) {
return this.rayData[index].hitY;
}
return 0;
}
disableThisSprite(args, util) {
const spriteName = util.target.getName();
this.disabledSprites.add(spriteName);
}
enableThisSprite(args, util) {
const spriteName = util.target.getName();
this.disabledSprites.delete(spriteName);
}
ensurePenExtension(args, util) {
const runtime = util.runtime;
if (!runtime.ext_pen) {
console.log('Loading pen extension...');
this.loadPenExtension();
// Give user feedback
return 'Pen extension loading... Try casting rays again.';
} else {
return 'Pen extension is ready!';
}
}
clearRays() {
this.rayData = [];
}
}
Scratch.extensions.register(new VisualRaycastExtension());
})(Scratch);