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);