diff --git a/eslint.config.js b/eslint.config.js index 748631e9ec..c1c697f666 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -587,4 +587,4 @@ export default defineConfig( '@typescript-eslint/no-require-imports': 'off', // Was 'error' } } -); +); \ No newline at end of file diff --git a/src/bundles/maze/manifest.json b/src/bundles/maze/manifest.json new file mode 100644 index 0000000000..e58ce2f2db --- /dev/null +++ b/src/bundles/maze/manifest.json @@ -0,0 +1,5 @@ +{ + "tabs": [ + "Maze" + ] +} diff --git a/src/bundles/maze/package.json b/src/bundles/maze/package.json new file mode 100644 index 0000000000..63dedcb578 --- /dev/null +++ b/src/bundles/maze/package.json @@ -0,0 +1,36 @@ +{ + "name": "@sourceacademy/bundle-maze", + "private": true, + "version": "1.0.0", + "dependencies": { + "@sourceacademy/modules-lib": "workspace:^", + "js-slang": "catalog:" + }, + "devDependencies": { + "@sourceacademy/conductor": "catalog:", + "@sourceacademy/modules-buildtools": "workspace:^", + "@sourceacademy/modules-testplugin": "workspace:^", + "typescript": "catalog:" + }, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./*": "./dist/*.js" + }, + "scripts": { + "build": "buildtools build bundle .", + "lint": "buildtools lint .", + "tsc": "buildtools tsc .", + "test": "buildtools test --project .", + "postinstall": "yarn compile", + "serve": "yarn buildtools serve", + "compile": "buildtools compile" + }, + "scripts-info": { + "build": "Compiles the given bundle to the output directory", + "lint": "Runs ESlint on the code in the bundle", + "serve": "Starts the modules server", + "test": "Runs unit tests defined for the bundle", + "tsc": "Runs the Typescript compiler and produces the library version of the bundle" + } +} diff --git a/src/bundles/maze/src/__tests__/functions.test.ts b/src/bundles/maze/src/__tests__/functions.test.ts new file mode 100644 index 0000000000..805169c6be --- /dev/null +++ b/src/bundles/maze/src/__tests__/functions.test.ts @@ -0,0 +1,59 @@ +import type { Context } from 'js-slang'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockContext = { + moduleContexts: { maze: {} as { state?: any } } +} as unknown as Context; +vi.mock(import('js-slang/context'), () => ({ default: mockContext })); + +describe('set_animation_speed', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('defaults animationSpeed to 2 before it is changed', async () => { + const funcs = await import('../functions'); + funcs.init(500, 500, 0, 0, 0); + expect(mockContext.moduleContexts.maze.state.animationSpeed).toBe(2); + }); + + it('updates state.animationSpeed when called before init is complete', async () => { + const funcs = await import('../functions'); + funcs.init(500, 500, 0, 0, 0); + funcs.set_animation_speed(10); + expect(mockContext.moduleContexts.maze.state.animationSpeed).toBe(10); + }); + + it('throws once initialisation is complete', async () => { + const funcs = await import('../functions'); + funcs.init(500, 500, 0, 0, 0); + funcs.complete_init(); + expect(() => funcs.set_animation_speed(10)).toThrow( + 'May not use initialization functions after initialization is complete!' + ); + }); + + it('throws when speed is 0', async () => { + const funcs = await import('../functions'); + funcs.init(500, 500, 0, 0, 0); + expect(() => funcs.set_animation_speed(0)).toThrow( + 'Animation speed must be a finite number greater than 0!' + ); + }); + + it('throws when speed is negative', async () => { + const funcs = await import('../functions'); + funcs.init(500, 500, 0, 0, 0); + expect(() => funcs.set_animation_speed(-5)).toThrow( + 'Animation speed must be a finite number greater than 0!' + ); + }); + + it('throws when speed is NaN', async () => { + const funcs = await import('../functions'); + funcs.init(500, 500, 0, 0, 0); + expect(() => funcs.set_animation_speed(NaN)).toThrow( + 'Animation speed must be a finite number greater than 0!' + ); + }); +}); diff --git a/src/bundles/maze/src/functions.ts b/src/bundles/maze/src/functions.ts new file mode 100644 index 0000000000..e3a3a04be5 --- /dev/null +++ b/src/bundles/maze/src/functions.ts @@ -0,0 +1,597 @@ +import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; +import context from 'js-slang/context'; +// import { +// head, +// tail, +// type List +// } from 'js-slang/dist/stdlib/list'; + +import { areaEquals, is_within_area, raycast, type Collision } from './helpers/areas'; +import { run_tests } from './helpers/tests'; +import type { + Area, + AreaFlags, + Maze, + Point, + PointWithRotation, Robot +} from './types'; + +// Default state before initialisation +const state: Maze = { + isInit: false, + hasCollided: false, + width: 500, + height: 500, + border: {}, + animationSpeed: 2, + robot: { x: 250, y: 250, rotation: 0, radius: 15 }, + areas: [], + areaLog: [], + actionLog: [], + tests: [], + message: '' +}; + +// sets the context to the state obj, mostly for convenience so i dont have to type context.... everytime +context.moduleContexts.maze.state = state; + +// ===== // +// SETUP // +// ===== // + +/** + * Shorthand function that initializes a new simulation with a map of size width * height + * Also sets the initial position and rotation of the robot + * + * @param width of the map + * @param height of the map + * @param posX initial X coordinate of the robot + * @param posY initial Y coordinate of the robot + * @param rotation initial rotation of the robot + */ +export function init( + width: number, + height: number, + posX: number, + posY: number, + rotation: number +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + const robot: Robot = { + x: posX, + y: posY, + rotation, + radius: 15 + }; + + // Update the map's dimensions + state.width = width; + state.height = height; + + // Update the robot + state.robot = robot; + + // Update the action log with the robot's starting position + state.actionLog = [{ type: 'begin', position: Object.assign({}, robot) }]; + + // Update the success message + state.message = 'Please run this in the assessments tab!'; +} + +/** + * Set the color of the map border + * + * @param color of the border (in any CSS-accepted format) + */ +export function set_border_color( + color: string +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + state.border.color = color; +} + +/** + * Set the width of the map border + * + * @param width of the border + */ +export function set_border_width( + width: number +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + state.border.width = width; +} + +/** + * Set the speed at which the robot animates its movement + * + * @param speed of the robot's animation + */ +export function set_animation_speed( + speed: number +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + if (!Number.isFinite(speed) || speed <= 0) throw new GeneralRuntimeError('Animation speed must be a finite number greater than 0!'); + + state.animationSpeed = speed; +} + +/** + * Create a new area with the given vertices and flags + * + * @param vertices of the area (in x-y pairs) + * @param isObstacle a boolean indicating if the area is an obstacle or not + * @param flags any additional flags the area may have (in key-value pairs) + */ +export function create_area( + vertices: [x: number, y: number][], + isObstacle: boolean, + flags: [key: string, value: any][] +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + // Store vertices as Point array + const parsedVertices: Point[] = vertices.map(v => ({ x: v[0], y: v[1] })); + + // Store flags as an object + const parsedFlags = flags.reduce((acc, f) => ({ ...acc, [f[0]]: f[1] }), {}); + + // Store the new area + state.areas.push({ + vertices: parsedVertices, + isObstacle, + flags: parsedFlags + }); +} + +/** + * Create a new rectangular, axis-aligned area + * + * @param x coordinate of the top left corner of the rectangle + * @param y coordinate of the top left corner of the rectangle + * @param width of the rectangle + * @param height of the rectangle + * @param isObstacle a boolean indicating if the area is an obstacle or not + * @param flags any additional flags the area may have (in key-value pairs) + */ +export function create_rect_area( + x: number, + y: number, + width: number, + height: number, + isObstacle: boolean, + flags: [key: string, value: any][] +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + create_area([ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height] + ], isObstacle, flags); +} + +/** + * Create a new obstacle + * + * @param vertices of the obstacle + */ +export function create_obstacle( + vertices: [x: number, y: number][] +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + create_area(vertices, true, []); +} + +/** + * Create a new rectangular, axis-aligned obstacle + * + * @param x coordinate of the top left corner of the rectangle + * @param y coordinate of the top left corner of the rectangle + * @param width of the rectangle + * @param height of the rectangle + */ +export function create_rect_obstacle( + x: number, + y: number, + width: number, + height: number +) { + // Init functions should not run after initialization + if (state.isInit) throw new GeneralRuntimeError('May not use initialization functions after initialization is complete!'); + + create_rect_area(x, y, width, height, true, []); +} + +/** + * Check if the robot has entered different areas with the given colors in order + * + * @param colors in the order visited + * @returns if the robot entered the given colors in order + */ +export function should_enter_colors( + colors: string[] +) { + state.tests.push({ + type: 'area', + test: (areas: Area[]) => { + const coloredAreas = areas + .filter((area: Area) => colors.includes(area.flags.color)) // Filter relevant colors + .filter(filterAdjacentDuplicateAreas); // Filter adjacent duplicates + + return coloredAreas.length === colors.length && coloredAreas.every(({ flags: { color } }, i) => color === colors[i]); // Check if each area has the expected color + } + }); +} + +/** + * Inform the simulator that the initialisation phase is complete + */ +export function complete_init() { + if (state.actionLog.length === 0) throw new GeneralRuntimeError('May not complete initialization without first running init()'); + + state.isInit = true; +} + +// ======= // +// SENSORS // +// ======= // + +/** + * Get the distance to the closest obstacle + * + * @returns the distance to the closest obstacle, or infinity (if robot is out of bounds) + */ +export function get_distance(): number { + // Check for all obstacles in the robot's path (including bounds) + const obstacleCollisions: Collision[] = robot_raycast((area: Area) => area.isObstacle); + + // If an obstacle is found, return its distance + return obstacleCollisions.length > 0 ? obstacleCollisions[0].distance - getRobot().radius : Infinity; +} + +// The maximum distance the robot can detect obstacles at +const SENSOR_RANGE: number = 15; + +/** + * Check if there is an obstacle within a predefined distance from the robot + */ +export function sense_obstacle(): boolean { + return get_distance() < SENSOR_RANGE; +} + +/** + * Get the color of the area under the robot + * + * @returns the color of the area under the robot + */ +export function get_color(): string { + return getRobotFlags().color; +} + +// ======= // +// ACTIONS // +// ======= // + +/** + * Move the robot forward by the specified distance + * + * @param distance to move forward + */ +export function move_forward( + distance: number +) { + // Ignore if robot has collided with an obstacle + if (state.hasCollided) return; + + // Get the robot + const robot = getRobot(); + + // Check for all areas in the robot's path + const collisions: Collision[] = robot_raycast() + .filter(col => col.distance < distance + robot.radius); + + for (const col of collisions) { + // Log the area + logArea(col.area); + + // Handle a collision with an obstacle + if (col.area.isObstacle) { + // Calculate find distance + distance = col.distance - robot.radius + 1; + + // Update the final message + state.message = `Collided with wall at (${robot.x + distance * Math.cos(robot.rotation)},${robot.y + distance * Math.sin(robot.rotation)})`; + + // Update state to reflect that the robot has collided with an obstacle + state.hasCollided = true; + } + } + + // Move the robot to its end position + robot.x = robot.x + distance * Math.cos(robot.rotation); + robot.y = robot.y + distance * Math.sin(robot.rotation); + + // Store the action in the actionLog + logAction('move', getPositionWithRotation()); +} + +// The distance from a wall a move_forward_to_wall() command will stop +const SAFE_DISTANCE_FROM_WALL: number = 10; + +/** + * Move the robot forward to within a predefined distance of the wall + */ +export function move_forward_to_wall() { + // Ignore if robot has collided with an obstacle + if (state.hasCollided) return; + + // Move forward the furthest possible safe distance + a lil extra offset + move_forward(Math.max(get_distance() - SAFE_DISTANCE_FROM_WALL, 0)); +} + +/** + * Rotate the robot clockwise by the given angle + * + * @param angle (in radians) to rotate clockwise + */ +export function rotate( + angle: number +) { + // Ignore if robot has collided with an obstacle + if (state.hasCollided) return; + + // Get the robot + const robot = getRobot(); + + // Update robot rotation + robot.rotation -= angle; + + // Normalise robot rotation within -pi and pi + robot.rotation = robot.rotation + (robot.rotation > Math.PI ? -2 * Math.PI : robot.rotation < -Math.PI ? 2 * Math.PI : 0); + + logAction('rotate', getPositionWithRotation()); +} + +/** + * Turn the robot 90 degrees to the left + */ +export function turn_left() { + rotate(Math.PI / 2); +} + +/** + * Turn the robot 90 degrees to the right + */ +export function turn_right() { + rotate(-Math.PI / 2); +} + +// ======= // +// TESTING // +// ======= // + +/** + * Run the stored tests in state + * + * @returns if all tests pass + */ +export function run_all_tests(): boolean { + return !state.hasCollided && run_tests(state); +} + +// ================== +// ================== +// PRIVATE FUNCTIONS: +// ================== +// ================== + +// =========== // +// MAP HELPERS // +// =========== // + +/** + * Get the active robot + * + * @returns the active robot + */ +function getRobot(): Robot { + return state.robot; +} + +/** + * Get the bound of the active map + * + * @returns the bounds of the active map + */ +function getBounds(): Point[] { + // Get active map + const { width, height } = state; + + return [ + { x: 0, y: 0 }, + { x: width, y: 0 }, + { x: width, y: height }, + { x: 0, y: height } + ]; +} + +/** + * Gets the position of the robot (with rotation) + * + * @returns the position of the robot (with rotation) + */ +function getPositionWithRotation(): PointWithRotation { + // Get the robot + const { x, y, rotation } = getRobot(); + + // Parse the robot + return { x, y, rotation }; +} + +// ======================== // +// RAYCAST AND AREA HELPERS // +// ======================== // + +/** + * Get the distance between the robot and area, if the robot is facing the area + * Casts 3 rays from the robot's left, middle and right + * + * @param filter the given areas (optional) + * @returns the minimum distance, or null (if no collision) + */ +function robot_raycast( + filter: (area: Area) => boolean = () => true +): Collision[] { + return state.areas + .filter(filter) // Apply filter + .map(area => robot_raycast_area(area)) // Raycast each area on the map + .concat([ + robot_raycast_area({ vertices: getBounds(), isObstacle: true, flags: {} }) // Raycast map bounds as well + ]) + .filter(col => col !== null) // Remove null collisions + .sort((a, b) => a.distance - b.distance); // Sort by distance +} + +/** + * Get the distance between the robot and area, if the robot is facing the area + * Casts 3 rays from the robot's left, middle and right + * + * @param area to check + * @returns the minimum distance, or null (if no collision) + */ +function robot_raycast_area( + area: Area +): Collision | null { + // Get the robot + const robot = getRobot(); + + const dx = Math.cos(robot.rotation), dy = Math.sin(robot.rotation); + + // raycast from 3 sources: left, middle, right + const raycast_sources: Point[] = [-1, 0, 1] + .map(mult => ({ + x: robot.x + mult * robot.radius * dy, + y: robot.y + mult * robot.radius * dx + })); + + // Raycast 3 times, one for each source + const collisions: Collision[] = raycast_sources + .map(source => raycast({ origin: source, target: { x: dx + source.x, y: dy + source.y } }, area)) + .filter(col => col !== null); + + // Return null if no intersection + return collisions.length > 0 + ? collisions.reduce((acc, col) => acc.distance > col.distance ? col : acc) + : null; +} + +/** + * Find the area the robot is in + * + * @returns if the robot is within the area + */ +function area_of_point( + point: Point +): Area | null { + // Return the first area the point is within + for (const area of state.areas) { + if (is_within_area(point, area)) return area; + } + + // Otherwise return null + return null; +} + +// =============== // +// LOGGING HELPERS // +// =============== // + +/** + * Add a movement to the action log + * + * @param type of action + * @param position to move to + */ +function logAction( + type: 'begin' | 'move' | 'rotate' | 'sensor', + position: PointWithRotation +) { + state.actionLog.push({ type, position }); +} + +/** + * Add an area to the area log + * + * @param area to log + */ +function logArea( + area: Area +) { + // Get the area log + const areaLog = state.areaLog; + + if ( + areaLog.length > 0 // Check for empty area log + && areaEquals(area, areaLog[areaLog.length - 1]) // Check if same area repeated + ) return; + + areaLog.push(area); +} + +// ============ // +// AREA HELPERS // +// ============ // + +/** + * Filter callback to remove adjacent duplicate areas + * + * @param area currently being checked + * @param i index of area + * @param areas the full array being filtered + * @returns if the current area is not a duplicate of the previous area + */ +const filterAdjacentDuplicateAreas = (area: Area, i: number, areas: Area[]): boolean => i === 0 // First one is always correct + || !areaEquals(area, areas[i - 1]); // Otherwise check for equality against previous area + +/** + * Gets the flags of the area containing the point (x, y) + * + * @param x coordinate + * @param y coordinate + * @returns the flags of the area containing (x, y) + */ +function getFlags( + x: number, + y: number +): AreaFlags { + // Find the area containing the point + const area: Area | null = area_of_point({ x, y }); + + return area === null ? {} : area.flags; +} + +/** + * Gets the flags of the area containing the robot + * + * @returns the flags of the robot's area + */ +function getRobotFlags(): AreaFlags { + // Get the robot + const robot = getRobot(); + + return getFlags(robot.x, robot.y); +} diff --git a/src/bundles/maze/src/helpers/areas.ts b/src/bundles/maze/src/helpers/areas.ts new file mode 100644 index 0000000000..ffd705a706 --- /dev/null +++ b/src/bundles/maze/src/helpers/areas.ts @@ -0,0 +1,140 @@ +import type { Area, Point } from '../types'; + +// A line segment between p1 and p2 +interface LineSegment { + p1: Point; + p2: Point; +} + +// A ray from origin towards target +interface Ray { + origin: Point; + target: Point; +} + +// A collision between a ray and an area +export interface Collision { + distance: number; + area: Area; +} + +/** + * Determine if a ray and a line segment intersect + * If they intersect, determine the distance from the ray's origin to the collision point + * + * @param ray being checked + * @param line to check intersection + * @returns the distance to the line segment, or infinity (if no collision) + */ +function getIntersection( + { origin, target }: Ray, + { p1, p2 }: LineSegment +): number { + const denom: number = (target.x - origin.x)*(p2.y - p1.y)-(target.y - origin.y)*(p2.x - p1.x); + + // If lines are collinear or parallel + if (denom === 0) return Infinity; + + // Intersection in ray "local" coordinates + const r: number = (((origin.y - p1.y) * (p2.x - p1.x)) - (origin.x - p1.x) * (p2.y - p1.y)) / denom; + + // Intersection in segment "local" coordinates + const s: number = (((origin.y - p1.y) * (target.x - origin.x)) - (origin.x - p1.x) * (target.y - origin.y)) / denom; + + // Check if line segment is behind ray, or not on the line segment + if (r < 0 || s < 0 || s > 1) return Infinity; + + return r; +} + +/** + * Get the shortest distance between a ray and an area + * + * @param ray being cast + * @param area to check + * @returns the collision with the minimum distance, or null (if no collision) + */ +export function raycast( + ray: Ray, + area: Area +): Collision | null { + const { vertices } = area; + + // Store the minimum distance + let distance = Infinity; + + for (let i = 0; i < vertices.length; i++) { + // Border line segment + const border: LineSegment = { + p1: { x: vertices[i].x, y: vertices[i].y }, + p2: { x: vertices[(i + 1) % vertices.length].x, y: vertices[(i + 1) % vertices.length].y } + }; + + // Compute the minimum distance + const distanceToIntersection: number = getIntersection(ray, border); + + // Save the new minimum, if necessary + if (distanceToIntersection < distance) distance = distanceToIntersection; + } + + // Return null if no collision + return distance < Infinity + ? { distance, area } + : null; +} + +/** + * Check if the point is within the area + * + * @param point potentially within the area + * @param area to check + * @returns if the point is within the area + */ +export function is_within_area( + point: Point, + area: Area +): boolean { + const { vertices } = area; + + // Cast a ray to the right of the point + const ray = { + origin: point, + target: { x: point.x + 1, y: point.y + 0 } + }; + + // Count the intersections + let intersections = 0; + + for (let i = 0; i < vertices.length; i++) { + // Border line segment + const border: LineSegment = { + p1: { x: vertices[i].x, y: vertices[i].y }, + p2: { x: vertices[(i + 1) % vertices.length].x, y: vertices[(i + 1) % vertices.length].y } + }; + + // Increment intersections if the ray intersects the border + if (getIntersection(ray, border) < Infinity) intersections++; + } + + // Even => Outside; Odd => Inside + return intersections % 2 === 1; +} + +/** + * Compare two areas for equality + * + * @param a the first area to compare + * @param b the second area to compare + * @returns if a == b + */ +export function areaEquals(a: Area, b: Area) { + if ( + a.vertices.length !== b.vertices.length // a and b must have an equal number of vertices + || a.vertices.some((v, i) => v.x !== b.vertices[i].x || v.y !== b.vertices[i].y) // a and b's vertices must be the same + || a.isObstacle !== b.isObstacle // Either both a and b or neither a nor b are obstacles + || Object.keys(a.flags).length !== Object.keys(b.flags).length // Check flags length equality + || Object.keys(a.flags).some(key => a.flags[key] !== b.flags[key]) // Check flag value equality + ) return false; + + return true; +} diff --git a/src/bundles/maze/src/helpers/tests.ts b/src/bundles/maze/src/helpers/tests.ts new file mode 100644 index 0000000000..c01aeb6b16 --- /dev/null +++ b/src/bundles/maze/src/helpers/tests.ts @@ -0,0 +1,23 @@ +import type { Area, Test } from '../types'; + +/** + * Run the stored tests in state + * + * @returns if all tests pass + */ +export function run_tests({ + areaLog, + tests +}: { + areaLog: Area[]; + tests: Test[]; +}): boolean { + // Run each test in order + for (const test of tests) { + // Can replace with a switch statement when more success conditions appear + if (test.type === 'area' && !test.test(areaLog)) return false; + } + + // If all tests pass, return true + return true; +} diff --git a/src/bundles/maze/src/index.ts b/src/bundles/maze/src/index.ts new file mode 100644 index 0000000000..4bef870edc --- /dev/null +++ b/src/bundles/maze/src/index.ts @@ -0,0 +1,15 @@ +/** + * The maze module allows us to guide a robot through a maze. + * + * @module maze + * @author Koh Wai Kei + * @author Justin Cheng + */ + +export { + init, complete_init, set_border_color, set_border_width, set_animation_speed, + create_area, create_rect_area, create_obstacle, create_rect_obstacle, + get_distance, sense_obstacle, get_color, + move_forward, move_forward_to_wall, rotate, turn_left, turn_right, + should_enter_colors, run_all_tests +} from './functions'; diff --git a/src/bundles/maze/src/types.ts b/src/bundles/maze/src/types.ts new file mode 100644 index 0000000000..8a97f64ae2 --- /dev/null +++ b/src/bundles/maze/src/types.ts @@ -0,0 +1,64 @@ +// A config storing border data +export interface BorderConfig { + color?: string; + width?: number; +} + +// A point (x, y) +export interface Point { + x: number; + y: number; +} + +// A point (x, y) with rotation +export interface PointWithRotation extends Point { + rotation: number; +} + +// The robot with position and radius +export interface Robot extends PointWithRotation { + radius: number; +} + +// A stored action +export interface Action { + type: 'begin' | 'move' | 'rotate' | 'sensor'; + position: PointWithRotation; +} + +// Any optional flags an area may have (e.g. color) +export interface AreaFlags { + [name: string]: any; +} + +// An area within the maze +export interface Area { + vertices: Point[]; + isObstacle: boolean; + flags: AreaFlags; +} + +// A test testing an area +export interface AreaTest { + type: 'area'; + test: (areas: Area[]) => boolean; +} + +// A test for the student to pass +export type Test = AreaTest; // | SomeOtherTest in future + +// The main maze state +export interface Maze { + isInit: boolean; + hasCollided: boolean; + width: number; + height: number; + border: BorderConfig; + animationSpeed: number; + robot: Robot; + areas: Area[]; + areaLog: Area[]; + actionLog: Action[]; + tests: Test[]; + message: string; +} diff --git a/src/bundles/maze/tsconfig.json b/src/bundles/maze/tsconfig.json new file mode 100644 index 0000000000..7f7d037900 --- /dev/null +++ b/src/bundles/maze/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "./src" + ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "typedocOptions": { + "name": "maze" + } +} diff --git a/src/tabs/Maze/package.json b/src/tabs/Maze/package.json new file mode 100644 index 0000000000..2b1c559513 --- /dev/null +++ b/src/tabs/Maze/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sourceacademy/tab-Maze", + "version": "1.0.0", + "private": true, + "dependencies": { + "@sourceacademy/bundle-maze": "workspace:^", + "@sourceacademy/modules-lib": "workspace:^", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@sourceacademy/modules-buildtools": "workspace:^", + "@types/react": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build": "buildtools build tab .", + "lint": "buildtools lint .", + "tsc": "buildtools tsc .", + "test": "buildtools test --project .", + "serve": "yarn buildtools serve" + }, + "type": "module", + "scripts-info": { + "build": "Compiles the given tab to the output directory", + "lint": "Runs ESlint on the code in the tab", + "serve": "Starts the modules server", + "test": "Runs unit tests defined for the tab", + "tsc": "Runs the Typescript compiler" + } +} diff --git a/src/tabs/Maze/src/MazeSimulation.tsx b/src/tabs/Maze/src/MazeSimulation.tsx new file mode 100644 index 0000000000..c516d3f466 --- /dev/null +++ b/src/tabs/Maze/src/MazeSimulation.tsx @@ -0,0 +1,295 @@ +import { run_tests } from '@sourceacademy/bundle-maze/helpers/tests'; +import type { Action, Area, BorderConfig, Maze, Robot } from '@sourceacademy/bundle-maze/types'; +import React, { useEffect, useRef, useState } from 'react'; + +/** + * Calculate the acute angle between 2 angles + * + * @param target rotation + * @param current rotation + * @returns the acute angle between + */ +const smallestAngle = ( + target: number, + current: number +) => { + const dr = (target - current) % (2 * Math.PI); + + if (dr > 0 && dr > Math.PI) return dr - (2 * Math.PI); + + if (dr < 0 && dr < -Math.PI) return dr + (2 * Math.PI); + + return dr; +}; + +/** + * Draw the borders of the map + * @param ctx for the canvas to draw on + * @param width of the map + * @param height of the map + */ +const drawBorders = ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + border: BorderConfig +) => { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, height); + ctx.lineTo(width, height); + ctx.lineTo(width, 0); + ctx.closePath(); + + ctx.strokeStyle = border.color || 'gray'; + ctx.lineWidth = border.width || 3; + ctx.stroke(); +}; + +// Draw the areas of the map +const drawAreas = ( + ctx: CanvasRenderingContext2D, + areas: Area[] +) => { + for (const { vertices, isObstacle, flags } of areas) { + ctx.beginPath(); + ctx.moveTo(vertices[0].x, vertices[0].y); + for (const vertex of vertices.slice(1)) { + ctx.lineTo(vertex.x, vertex.y); + } + ctx.closePath(); + + ctx.fillStyle = isObstacle // Obstacles are gray + ? 'rgba(169, 169, 169, 0.5)' + : flags.color || 'none'; // Areas may have color + ctx.fill(); // Fill the polygon + + ctx.strokeStyle = 'rgb(53, 53, 53)'; // Set the stroke color + ctx.lineWidth = 2; // Set the border width + ctx.stroke(); // Stroke the polygon + } +}; + +// Draw the robot +const drawRobot = ( + ctx: CanvasRenderingContext2D, + { x, y, rotation, radius }: Robot +) => { + // Save the background state + ctx.save(); + + // translates the origin of the canvas to the center of the robot, then rotate + ctx.translate(x, y); + ctx.rotate(rotation); + + ctx.beginPath(); // Begin drawing robot + + ctx.arc(0, 0, radius, 0, Math.PI * 2, false); // Full circle (0 to 2π radians) + + ctx.fillStyle = 'black'; // Set the fill color + ctx.fill(); // Fill the circle + ctx.closePath(); + + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(0, 0); + ctx.stroke(); + + // Restore the background state + ctx.restore(); +}; + +/** + * Render the current game state + */ +const drawAll = ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + border: BorderConfig, + areas: Area[], + robot: Robot +) => { + ctx.reset(); + drawAreas(ctx, areas); + drawBorders(ctx, width, height, border); + drawRobot(ctx, robot); +}; + +/** + * React Component props for the Tab. + */ +interface MapProps { + state: Maze; +} + +const MazeSimulation: React.FC = ({ + state: { + hasCollided, + width, + height, + border, + animationSpeed, + robot: { radius: robotSize }, + areas, + actionLog, + areaLog, + tests, + message + } +}) => { + // Store animation status + // 0 => Loaded / Loading + // 1 => Running + // 2 => Paused + // 3 => Finished + const [animationStatus, setAnimationStatus] = useState<0 | 1 | 2 | 3>(0); + + // Store animation pause + const animationPauseUntil = useRef(null); + + // Store current action id + const currentAction = useRef(1); + + // Store robot status + const robot = useRef({ x: 0, y: 0, rotation: 0, radius: 1 }); + + // Ensure canvas is preloaded correctly + useEffect(() => { + // Only load if animationStatus is 0 + if (animationStatus !== 0) return; + + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Reset current action + currentAction.current = 1; + + // Reset robot position if action log has actions + if (actionLog.length > 0) robot.current = Object.assign({}, { radius: robotSize }, actionLog[0].position); + + // Update canvas dimensions + canvas.width = width; + canvas.height = height; + + drawAll(ctx, width, height, border, areas, robot.current); + }, [animationStatus]); + + // Handle animation + useEffect(() => { + if (animationStatus !== 1) return; + + const interval = setInterval(() => { + // Retrieve canvas 2d context + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // End animation after action log complete + if (currentAction.current >= actionLog.length) return setAnimationStatus(3); + + // Skip animation if paused + if (animationPauseUntil.current !== null) { + if (Date.now() > animationPauseUntil.current) { + animationPauseUntil.current = null; + currentAction.current++; + } + return; + } + + // Get current action + const { type, position: target }: Action = actionLog[currentAction.current]; + + switch (type) { + case 'move': { + // Calculate the distance to target point + const dx = target.x - robot.current.x; + const dy = target.y - robot.current.y; + const distance = Math.sqrt( + (target.x - robot.current.x) ** 2 + + (target.y - robot.current.y) ** 2 + ); + + // If distance to target point is small + if (distance <= animationSpeed) { + // Snap to the target point + robot.current.x = target.x; + robot.current.y = target.y; + + // Move on to next action + currentAction.current++; + break; + } + + // Move the robot towards the target + robot.current.x += (dx / distance) * animationSpeed; + robot.current.y += (dy / distance) * animationSpeed; + break; + } case 'rotate': { + // Scale rotation with animationSpeed; 0.05 rad/unit keeps the default + // animationSpeed of 2 producing the previous hardcoded 0.1 rad/tick + const rotationStep = animationSpeed * 0.05; + + // If rotation is close to target rotation + if (Math.abs((target.rotation - robot.current.rotation) % (2 * Math.PI)) < rotationStep) { + // Snap to the target point + robot.current.rotation = target.rotation; + + // Move on to next action + currentAction.current++; + break; + } + + robot.current.rotation += smallestAngle(target.rotation, robot.current.rotation) > 0 ? rotationStep : -rotationStep; + + if (robot.current.rotation > Math.PI) { + robot.current.rotation -= 2 * Math.PI; + } + + if (robot.current.rotation < Math.PI) { + robot.current.rotation += 2 * Math.PI; + } + break; + } + case 'sensor': + animationPauseUntil.current = Date.now() + 500; + break; + case 'begin': + robot.current = Object.assign({}, { radius: robot.current.radius }, target); + } + + drawAll(ctx, width, height, border, areas, robot.current); + }, 10); + + return () => clearInterval(interval); + }, [animationStatus]); + + // Store a reference to the HTML canvas + const canvasRef = useRef(null); + + return ( + <> +
+ + {animationStatus === 0 + ? + : animationStatus === 1 + ? + : animationStatus === 2 + ? + : } + {animationStatus === 3 && {!hasCollided && run_tests({ tests, areaLog }) ? 'Success! 🎉' : message}} +
+
+ +
+ + ); +}; + +export default MazeSimulation; diff --git a/src/tabs/Maze/src/index.tsx b/src/tabs/Maze/src/index.tsx new file mode 100644 index 0000000000..fa659ea9e6 --- /dev/null +++ b/src/tabs/Maze/src/index.tsx @@ -0,0 +1,43 @@ +import type { Maze as MazeState } from '@sourceacademy/bundle-maze/types'; +import { defineTab, getModuleState } from '@sourceacademy/modules-lib/tabs/utils'; +import type { ModuleTab } from '@sourceacademy/modules-lib/types'; +import MazeSimulation from './MazeSimulation'; + +/** + * Renders the maze simulation in the assessment workspace + * @author Koh Wai Kei + * @author Justin Cheng + */ +const Maze: ModuleTab = ({ debuggerCtx }) => { + const state = getModuleState(debuggerCtx, 'maze')!; + return ; +}; + +export default defineTab({ + /** + * This function will be called to determine if the component will be + * rendered. + */ + toSpawn: ctx => { + const state = getModuleState(ctx, 'maze'); + return state !== null && state.isInit; + }, + + /** + * This function will be called to render the module tab in the side contents + * on Source Academy frontend. + */ + body: debuggerCtx => , + + /** + * The Tab's icon tooltip in the side contents on Source Academy frontend. + */ + label: 'Maze', + + /** + * BlueprintJS IconName element's name, used to render the icon which will be + * displayed in the side contents panel. + * @see https://blueprintjs.com/docs/#icons + */ + iconName: 'layout-grid', +}); diff --git a/src/tabs/Maze/tsconfig.json b/src/tabs/Maze/tsconfig.json new file mode 100644 index 0000000000..02bf137484 --- /dev/null +++ b/src/tabs/Maze/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "./src" + ] +} diff --git a/yarn.lock b/yarn.lock index d754477251..e4717e7235 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4201,6 +4201,19 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/bundle-maze@workspace:^, @sourceacademy/bundle-maze@workspace:src/bundles/maze": + version: 0.0.0-use.local + resolution: "@sourceacademy/bundle-maze@workspace:src/bundles/maze" + dependencies: + "@sourceacademy/conductor": "catalog:" + "@sourceacademy/modules-buildtools": "workspace:^" + "@sourceacademy/modules-lib": "workspace:^" + "@sourceacademy/modules-testplugin": "workspace:^" + js-slang: "catalog:" + typescript: "catalog:" + languageName: unknown + linkType: soft + "@sourceacademy/bundle-midi@workspace:^, @sourceacademy/bundle-midi@workspace:src/bundles/midi": version: 0.0.0-use.local resolution: "@sourceacademy/bundle-midi@workspace:src/bundles/midi" @@ -4830,6 +4843,20 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/tab-Maze@workspace:src/tabs/Maze": + version: 0.0.0-use.local + resolution: "@sourceacademy/tab-Maze@workspace:src/tabs/Maze" + dependencies: + "@sourceacademy/bundle-maze": "workspace:^" + "@sourceacademy/modules-buildtools": "workspace:^" + "@sourceacademy/modules-lib": "workspace:^" + "@types/react": "catalog:" + react: "catalog:" + react-dom: "catalog:" + typescript: "catalog:" + languageName: unknown + linkType: soft + "@sourceacademy/tab-Nbody@workspace:src/tabs/Nbody": version: 0.0.0-use.local resolution: "@sourceacademy/tab-Nbody@workspace:src/tabs/Nbody"