diff --git a/src/app/components/blockly-editor/blockly-editor.component.ts b/src/app/components/blockly-editor/blockly-editor.component.ts index a46b0af..7b15177 100644 --- a/src/app/components/blockly-editor/blockly-editor.component.ts +++ b/src/app/components/blockly-editor/blockly-editor.component.ts @@ -23,7 +23,6 @@ export class BlocklyEditorComponent implements AfterViewInit { private modeService = inject(ModeService); @ViewChild('blocklyDiv') blocklyDiv!: ElementRef; - //@ViewChild('blocklyArea') blocklyArea!: ElementRef; @Input() toolbox = signal({ kind: 'flyoutToolbox', @@ -84,12 +83,9 @@ export class BlocklyEditorComponent implements AfterViewInit { }); this.workspace.registerButtonCallback('addNewBlock', () => { - //this.openBlocksModal(); this.openModal.emit(); }); - //this.resizeBlockly(); - const jsonWorkspace = JSON.parse(this.workspaceJSON); Blockly.serialization.workspaces.load(jsonWorkspace, this.workspace); } diff --git a/src/app/components/scene/scene.component.html b/src/app/components/scene/scene.component.html index 7ffd8b7..d90dd92 100644 --- a/src/app/components/scene/scene.component.html +++ b/src/app/components/scene/scene.component.html @@ -1,4 +1,4 @@ -
+
@if (modeService.getMode() === 'teacher') { @@ -28,5 +28,14 @@ }
- +
+ @for (obj of sceneObjects; track obj.id) { + + } +
+ +
diff --git a/src/app/components/scene/scene.component.ts b/src/app/components/scene/scene.component.ts index e44623f..f1f340b 100644 --- a/src/app/components/scene/scene.component.ts +++ b/src/app/components/scene/scene.component.ts @@ -12,11 +12,13 @@ import { import {SceneObject} from '../../models/scene-object'; import {ButtonComponent} from '../button/button.component'; import {ModeService} from '../../services/mode.service'; +import {NgClass} from '@angular/common'; @Component({ selector: 'blearn-scene', imports: [ - ButtonComponent + ButtonComponent, + NgClass ], templateUrl: './scene.component.html', }) @@ -27,11 +29,15 @@ export class SceneComponent implements AfterViewInit { @ViewChild('scene') scene!: ElementRef; @Input() isRunning = signal(false); + @Input() sceneObjects: SceneObject[] = []; + @Input() selectedObject = signal(undefined); + @Output() runCode = new EventEmitter(); - @Output() stopCode = new EventEmitter(); + @Output() sceneObjectsChange = new EventEmitter(); + @Output() objectAdded = new EventEmitter(); + @Output() objectSelected = new EventEmitter(); private ctx: CanvasRenderingContext2D | null = null; - protected sceneObjects: Array = []; private draggingObject: SceneObject | null = null; private offsetX: number = 0; private offsetY: number = 0; @@ -47,35 +53,32 @@ export class SceneComponent implements AfterViewInit { this.canvas.nativeElement.width = this.scene.nativeElement.offsetWidth; this.canvas.nativeElement.height = this.scene.nativeElement.offsetHeight; - // Create an image object - const img = new Image(); - img.src = 'https://avatars.githubusercontent.com/u/105555875?v=4'; // Replace with your image URL - img.onload = () => { - // Once the image is loaded, draw it to the canvas - this.sceneObjects.push({img, x: 50, y: 50, rotation: 0, width: 100, height: 100}); - this.drawImages(); - }; + const imageLoadPromises = this.sceneObjects.map(obj => { + return new Promise((resolve) => { + const img = new Image(); + img.src = obj.imgSrc; + img.onload = () => { + obj.img = img; + resolve(); + } + }); + }); - // Set up mouse event listeners for dragging + Promise.all(imageLoadPromises).then(() => this.drawImages()); this.setupMouseEvents(); } protected addObject() { - const img = new Image(); - img.src = 'https://avatars.githubusercontent.com/u/105555875?v=4'; // Replace with your image URL - img.onload = () => { - // Once the image is loaded, draw it to the canvas - this.sceneObjects.push({img, x: 50, y: 50, rotation: 0, width: 100, height: 100}); - this.drawImages(); - }; + this.objectAdded.emit(); } private setupMouseEvents() { this.canvas.nativeElement.addEventListener('mousedown', (e: MouseEvent) => { + if (this.isRunning()) return; + const mouseX = e.offsetX; const mouseY = e.offsetY; - // Check if the mouse click is on one of the images for (let sceneObj of this.sceneObjects) { if (mouseX >= sceneObj.x && mouseX <= sceneObj.x + sceneObj.width && mouseY >= sceneObj.y && mouseY <= sceneObj.y + sceneObj.height) { this.draggingObject = sceneObj; @@ -86,6 +89,8 @@ export class SceneComponent implements AfterViewInit { }); this.canvas.nativeElement.addEventListener('mousemove', (e: MouseEvent) => { + if (this.isRunning()) return; + if (this.draggingObject) { const mouseX = e.offsetX; const mouseY = e.offsetY; @@ -99,50 +104,45 @@ export class SceneComponent implements AfterViewInit { }); this.canvas.nativeElement.addEventListener('mouseup', () => { + if (this.isRunning()) return; + + if (this.draggingObject !== null) this.sceneObjectsChange.emit(); this.draggingObject = null; }); this.canvas.nativeElement.addEventListener('mouseleave', () => { + if (this.isRunning()) return; + this.draggingObject = null; }); } - private drawImages() { + public drawImages() { if (!this.ctx) return; - // Clear the canvas this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height); - // Draw each image on the canvas - for (let imgObj of this.sceneObjects) { - this.ctx.drawImage(imgObj.img, imgObj.x, imgObj.y, imgObj.width, imgObj.height); - } - } - - moveTo(x: number, y: number) { - if (this.sceneObjects.length > 0) { - const obj = this.sceneObjects[0]; - obj.x = x; - obj.y = y; - this.drawImages(); - } - } - - moveForward(steps: number) { - const obj = this.sceneObjects[0]; - obj.x += steps; - this.drawImages(); - } - - setDirection(angle: number) { - - } + for (let obj of this.sceneObjects) { + this.ctx.save(); - turnLeft(angle: number) { - - } + if (obj.id === this.selectedObject()) { + this.ctx.shadowColor = 'red'; + this.ctx.shadowBlur = 20; + this.ctx.shadowOffsetX = 0; + this.ctx.shadowOffsetY = 0; + } - turnRight(angle: number) { + if (!obj.img) { + const img = new Image(); + img.src = obj.imgSrc; + img.onload = () => { + obj.img = img; + this.ctx?.drawImage(obj.img!, obj.x, obj.y, obj.width, obj.height); + } + } else + this.ctx.drawImage(obj.img!, obj.x, obj.y, obj.width, obj.height); + this.ctx.restore(); + } } } diff --git a/src/app/layout/header/header.component.html b/src/app/layout/header/header.component.html index 0b30345..4b6dd3d 100644 --- a/src/app/layout/header/header.component.html +++ b/src/app/layout/header/header.component.html @@ -17,5 +17,6 @@ teacherStyle="bg-student text-white" (click)="switchMode()" data-testid="button" + routerLink="" /> diff --git a/src/app/models/activity.ts b/src/app/models/activity.ts index bafd458..ef5d936 100644 --- a/src/app/models/activity.ts +++ b/src/app/models/activity.ts @@ -1,3 +1,5 @@ +import {SceneObject} from './scene-object'; + export interface Activity { id: string, title: string, @@ -7,5 +9,6 @@ export interface Activity { toolboxInfo: { BLOCK_LIMITS: { [key: string]: number }, toolboxDefinition: string, - } + }, + sceneObjects: SceneObject[] } diff --git a/src/app/models/scene-object.ts b/src/app/models/scene-object.ts index 0fdd024..4a6dc8e 100644 --- a/src/app/models/scene-object.ts +++ b/src/app/models/scene-object.ts @@ -1,8 +1,34 @@ -export interface SceneObject { - img: HTMLImageElement; - x: number; - y: number; - rotation: number; - width: number; - height: number; +export class SceneObject { + constructor( + public id: string, + public imgSrc: string, + public x: number, + public y: number, + public rotation: number, + public width: number, + public height: number, + public workspace: string, + public img?: HTMLImageElement, + ) {} + + moveForward(steps: number) { + this.x += steps; + } + + moveTo(x: number, y: number) { + this.x = x; + this.y = y; + } + + setDirection(angle: number) { + + } + + turnLeft(angle: number) { + + } + + turnRight(angle: number) { + + } } diff --git a/src/app/pages/activity-detail/activity-detail.component.html b/src/app/pages/activity-detail/activity-detail.component.html index 924949a..bc04cc1 100644 --- a/src/app/pages/activity-detail/activity-detail.component.html +++ b/src/app/pages/activity-detail/activity-detail.component.html @@ -46,15 +46,19 @@ [workspaceJSON]="activity()!.workspace" [BLOCKS_LIMITS]="BLOCK_LIMITS" (openModal)="openBlocksModal()" - (updateLimits)="updateToolboxLimits(workspace)" + (updateLimits)="updateToolboxLimits()" (saveWorkspace)="saveWorkspace(false)" />
diff --git a/src/app/pages/activity-detail/activity-detail.component.ts b/src/app/pages/activity-detail/activity-detail.component.ts index ad048c7..b4a1c8c 100644 --- a/src/app/pages/activity-detail/activity-detail.component.ts +++ b/src/app/pages/activity-detail/activity-detail.component.ts @@ -2,7 +2,6 @@ import { AfterViewInit, Component, computed, - ElementRef, inject, OnDestroy, signal, @@ -25,6 +24,8 @@ import {DescriptionModalComponent} from '../../components/description-modal/desc import {NgClass} from '@angular/common'; import {SceneComponent} from '../../components/scene/scene.component'; import {javascriptGenerator} from 'blockly/javascript'; +import {SceneObject} from '../../models/scene-object'; +import genUniqueId from '../../utils/genUniqueId'; @Component({ selector: 'blearn-activity-detail', @@ -47,8 +48,6 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { @ViewChild(BlocklyEditorComponent) blocklyEditorComponent!: BlocklyEditorComponent; @ViewChild(SceneComponent) sceneComponent!: SceneComponent; @ViewChild('modalHost', {read: ViewContainerRef}) modalHost!: ViewContainerRef; - @ViewChild('scene') scene!: ElementRef; - @ViewChild('canvas') canvas!: ElementRef; protected workspace!: Blockly.WorkspaceSvg; protected toolbox = signal({ @@ -58,12 +57,15 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { {kind: '', type: ''}, ] }); + protected selectedObject = signal(undefined); protected readonly BLOCK_LIMITS: Map; - private interpreter: Interpreter | null = null; + private runningInterpreters = 0; protected isRunning = signal(false); + public objectsCode = new Map(); + private activityId = toSignal( this.route.paramMap.pipe( map(params => params.get('id') || null) @@ -91,17 +93,79 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { this.activity.set(computedActivity()); this.toolbox.set(JSON.parse(this.activity()!.toolboxInfo.toolboxDefinition)); this.BLOCK_LIMITS = new Map(Object.entries(this.activity()!.toolboxInfo.BLOCK_LIMITS)); + + if (this.activity()!.sceneObjects.length > 0) { + + const restoredObjects = this.activity()!.sceneObjects.map(obj => + new SceneObject(obj.id, obj.imgSrc, obj.x, obj.y, obj.rotation, obj.width, obj.height, obj.workspace) + ); + + this.activity.set({...this.activity()!, sceneObjects: restoredObjects}); + } } ngAfterViewInit(): void { this.workspace = this.blocklyEditorComponent.getWorkspace(); + const jsonWorkspace = JSON.stringify(Blockly.serialization.workspaces.save(this.workspace)); + this.activity.set({...this.activity()!, workspace: jsonWorkspace}); + + if (this.activity()!.sceneObjects.length > 0) { + this.activity()!.sceneObjects.forEach(sceneObject => this.generateCode(sceneObject)); + this.selectSceneObject(this.activity()!.sceneObjects[0].id); + } } ngOnDestroy(): void { this.saveWorkspace(true); } + public findSceneObjectById(id: string): SceneObject | undefined { + return this.activity()!.sceneObjects.find(obj => obj.id === id); + } + + protected createSceneObject() { + const img = new Image(); + img.src = 'https://avatars.githubusercontent.com/u/105555875?v=4'; + img.onload = () => { + const newObject: SceneObject = new SceneObject( + genUniqueId(), + img.src, + 0, + 0, + 0, + 100, + 100, + this.activity()!.workspace, + img + ); + + Blockly.serialization.workspaces.load(JSON.parse(newObject.workspace), this.workspace); + this.workspace.updateToolbox(this.toolbox()); + + this.selectedObject.set(newObject.id); + + console.log(this.objectsCode); + + this.activity()!.sceneObjects.push(newObject); + this.sceneComponent.drawImages(); + } + } + + protected selectSceneObject(id: string) { + this.selectedObject.set(id); + + const obj = this.findSceneObjectById(id); + Blockly.serialization.workspaces.load(JSON.parse(obj!.workspace), this.workspace); + + this.sceneComponent.drawImages(); + } + protected openBlocksModal() { + if (this.activity()!.sceneObjects.length === 0) { + alert('You need to create an object to start adding blocks'); + return; + } + const modalRef = this.modalHost.createComponent(BlocksModalComponent); modalRef.instance.BLOCKS_LIMIT = this.BLOCK_LIMITS; @@ -117,7 +181,7 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { this.toolbox.set(newToolbox); } - this.updateToolboxLimits(this.workspace); + this.updateToolboxLimits(); }); modalRef.instance.blockRemoved.subscribe(type => { @@ -133,7 +197,7 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { } } else if (this.BLOCK_LIMITS.has(type) && this.BLOCK_LIMITS.get(type)! > 1) this.BLOCK_LIMITS.set(type, this.BLOCK_LIMITS.get(type)! - 1); - this.updateToolboxLimits(this.workspace); + this.updateToolboxLimits(); }); modalRef.instance.close.subscribe(() => { this.saveWorkspace(false); @@ -150,9 +214,9 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { modalRef.instance.close.subscribe(() => modalRef.destroy()); } - protected updateToolboxLimits(workspace: Blockly.WorkspaceSvg) { + protected updateToolboxLimits() { const blockCounts = new Map(); - workspace.getAllBlocks(false).forEach(block => { + this.workspace.getAllBlocks(false).forEach(block => { const type = block.type; blockCounts.set(type, (blockCounts.get(type) || 0) + 1); }); @@ -171,7 +235,7 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { }) } - workspace.updateToolbox(newToolbox); + this.workspace.updateToolbox(newToolbox); } protected updateTitle(newTitle: string) { @@ -195,73 +259,108 @@ export class ActivityDetailComponent implements AfterViewInit, OnDestroy { } } + private generateCode(sceneObject: SceneObject) { + Blockly.serialization.workspaces.load(JSON.parse(sceneObject.workspace), this.workspace); + javascriptGenerator.init(this.workspace); + + const startBlock = this.workspace.getTopBlocks(true) + .find(block => block.type === 'event_start'); + + if (startBlock) { + const code = javascriptGenerator.blockToCode(startBlock) as string; + this.objectsCode.set(sceneObject.id, code); + } + } + saveWorkspace(onStorage: boolean) { + if (this.selectedObject()) { + javascriptGenerator.init(this.workspace); + const startBlock = this.workspace.getTopBlocks(true) + .find(block => block.type === 'event_start'); + + if (startBlock) { + const code = javascriptGenerator.blockToCode(startBlock) as string; + this.objectsCode.set(this.selectedObject()!, code); + } + } + const jsonWorkspace = Blockly.serialization.workspaces.save(this.workspace); if (onStorage && this.modeService.getMode() === 'teacher') this.toolbox().contents.shift(); const jsonToolbox = JSON.stringify(this.toolbox()); const workspaceJSON = JSON.stringify(jsonWorkspace); + + if (this.selectedObject()) { + const obj = this.findSceneObjectById(this.selectedObject()!); + obj!.workspace = workspaceJSON; + } this.activity.set({ ...this.activity()!, - workspace: workspaceJSON, toolboxInfo: {BLOCK_LIMITS: Object.fromEntries(this.BLOCK_LIMITS), toolboxDefinition: jsonToolbox} }); - if (onStorage) this.activityService.updateActivity(this.activityId()!, this.activity()!); + if (onStorage) { + this.activity()!.sceneObjects.map(obj => obj.img = undefined); + this.activityService.updateActivity(this.activityId()!, this.activity()!); + } } - runInterpreter(code: string) { - this.interpreter = new Interpreter(code, this.initApi.bind(this)); - this.stepExecution(); - } + protected onRunCode() { + this.runningInterpreters = this.objectsCode.size; - stepExecution() { - if (!this.interpreter || !this.isRunning()) return; + this.objectsCode.forEach((v, k) => { + console.log('Executing code of object with id ', k); + this.isRunning.set(true); + this.runInterpreter(v, k); + }); + } - const hasMoreCode = this.interpreter.step(); - if (hasMoreCode) { - setTimeout(() => this.stepExecution(), 0.5); - } else { - this.isRunning.set(false); - console.log('Execution finished'); - } + runInterpreter(code: string, id: string) { + const interpreterInstance = new Interpreter(code, this.initApi(id)); + this.stepExecution(interpreterInstance); } - initApi(interpreter: Interpreter, globalObject: any) { - const addFunction = (name: string, fn: Function) => { + private initApi(id: string) { + return (interpreter: Interpreter, globalObject: any) => { + const object = this.findSceneObjectById(id); + if (!object) return; + + const addFunction = (name: string, fn: Function) => { + interpreter.setProperty( + globalObject, + name, + interpreter.createNativeFunction(fn.bind(object)) + ); + }; + + addFunction('moveForward', object.moveForward); + addFunction('moveTo', object.moveTo); + addFunction('setDirection', object.setDirection) + addFunction('turnLeft', object.turnLeft); + addFunction('turnRight', object.turnRight); + interpreter.setProperty( globalObject, - name, - interpreter.createNativeFunction(fn.bind(this.sceneComponent)) + 'waitSeconds', + interpreter.createAsyncFunction((seconds: number, callback: Function) => { + setTimeout(() => callback(), seconds * 1000); + }) ); }; - - addFunction('moveForward', this.sceneComponent.moveForward); - addFunction('moveTo', this.sceneComponent.moveTo); - addFunction('setDirection', this.sceneComponent.setDirection) - addFunction('turnLeft', this.sceneComponent.turnLeft); - addFunction('turnRight', this.sceneComponent.turnRight); - - interpreter.setProperty( - globalObject, - 'waitSeconds', - interpreter.createAsyncFunction((seconds: number, callback: Function) => { - setTimeout(() => callback(), seconds * 1000); - }) - ); } - protected onRunCode() { - javascriptGenerator.init(this.workspace); - const startBlock = this.workspace.getTopBlocks(true) - .find(block => block.type === 'event_start'); + private stepExecution(interpreter: Interpreter) { + if (!this.isRunning()) return; - if (!startBlock) { - alert('You should start your program with the "Start program" block!'); - return; - } - const code = javascriptGenerator.blockToCode(startBlock) as string; - console.log(code); + const hasMoreCode = interpreter.step(); + if (hasMoreCode) { + this.sceneComponent.drawImages(); + setTimeout(() => this.stepExecution(interpreter), 0.1); + } else { + this.runningInterpreters--; - this.isRunning.set(true); - this.runInterpreter(code); + if (this.runningInterpreters === 0) { + this.isRunning.set(false); + console.log('Execution finished'); + } + } } } diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 0ef30ff..70692d2 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -42,7 +42,8 @@ export class HomeComponent { toolboxInfo: { toolboxDefinition: '{"kind": "flyoutToolbox", "contents": [{ "kind": "", "text": "", "callbackKey": ""}, {"kind": "", "type": ""}]}', BLOCK_LIMITS: {} - } + }, + sceneObjects: [] }; this.activityService.addActivity(newActivity); this.activityList.set(this.activityService.loadActivities()); @@ -93,6 +94,7 @@ export class HomeComponent { dueDate: jsonData.dueDate, workspace: jsonData.workspace, toolboxInfo: jsonData.toolboxInfo, + sceneObjects: jsonData.sceneObjects, } } }