diff --git a/eslint.config.js b/eslint.config.js index 775263e5f..634cc9063 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -188,4 +188,4 @@ export default tseslint.config( 'jest/valid-describe-callback': 'off' } } -); +); \ No newline at end of file diff --git a/modules.json b/modules.json index f8a3abf08..c3b68d043 100644 --- a/modules.json +++ b/modules.json @@ -117,6 +117,11 @@ "Nbody" ] }, + "robot_minigame": { + "tabs": [ + "RobotMaze" + ] + }, "unittest": { "tabs": [ "Unittest" diff --git a/package.json b/package.json index b69d16567..743d60b68 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "prepare": "husky", "postinstall": "patch-package && yarn scripts:build", "scripts": "node --max-old-space-size=4096 scripts/dist/bin.js", - "serve": "http-server --cors=* -c-1 -p 8022 ./build", + "serve": "http-server --cors -c-1 -p 8022 ./build", "template": "yarn scripts template", "test": "yarn scripts test", "test:all": "yarn test && yarn scripts:test", diff --git a/src/bundles/robot_minigame/functions.ts b/src/bundles/robot_minigame/functions.ts new file mode 100644 index 000000000..977fe6ab8 --- /dev/null +++ b/src/bundles/robot_minigame/functions.ts @@ -0,0 +1,576 @@ +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 { + Point, PointWithRotation, Robot, + Action, + AreaFlags, Area, + AreaTest, + RobotMinigame +} from './types'; + +// Default state before initialisation +const state: RobotMinigame = { + isInit: false, + hasCollided: false, + width: 500, + height: 500, + 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.robot_minigame.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 Error('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!'; +} + +/** + * Create a new area with the given vertices and flags + * + * @param vertices of the area in alternating 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 + */ +export function create_area( + vertices: number[], + isObstacle: boolean, + flags: any[] +) { + // Init functions should not run after initialization + if (state.isInit) throw new Error('May not use initialization functions after initialization is complete!'); + + if (vertices.length % 2 !== 0) throw new Error('Odd number of vertice x-y coordinates given (expected even)'); + + if (flags.length % 2 !== 0) throw new Error('Odd number of flag arguments given (expected even)'); + + // Store vertices as Point array + const parsedVertices: Point[] = []; + + // Parse x-y pairs into Points + for (let i = 0; i < vertices.length / 2; i++) { + parsedVertices[i] = { + x: vertices[i * 2], + y: vertices[i * 2 + 1] + }; + } + + // Store flags as an object + const parsedFlags = {}; + + // Parse flag-value pairs into flags + for (let i = 0; i < flags.length / 2; i++) { + // Retrieve flag + const flag = flags[i * 2]; + + // Check flag is string + if (typeof flag !== 'string') throw new Error(`Flag arguments must be strings (${flag} is a ${typeof flag})`); + + // Add flag to object + parsedFlags[flag] = flags[i * 2 + 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 + */ +export function create_rect_area( + x: number, + y: number, + width: number, + height: number, + isObstacle: boolean, + flags: any[] +) { + // Init functions should not run after initialization + if (state.isInit) throw new Error('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: number[] +) { + // Init functions should not run after initialization + if (state.isInit) throw new Error('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 Error('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 + } + } as AreaTest); +} + +/** + * Inform the simulator that the initialisation phase is complete + */ +export function complete_init() { + if (state.actionLog.length === 0) throw new Error('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} as Action); +} + +/** + * 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/robot_minigame/helpers/areas.ts b/src/bundles/robot_minigame/helpers/areas.ts new file mode 100644 index 000000000..389fb2f66 --- /dev/null +++ b/src/bundles/robot_minigame/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/robot_minigame/helpers/tests.ts b/src/bundles/robot_minigame/helpers/tests.ts new file mode 100644 index 000000000..4c1ee145f --- /dev/null +++ b/src/bundles/robot_minigame/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/robot_minigame/index.ts b/src/bundles/robot_minigame/index.ts new file mode 100644 index 000000000..ed5107f33 --- /dev/null +++ b/src/bundles/robot_minigame/index.ts @@ -0,0 +1,14 @@ +/** + * The robot_minigame module allows us to control a robot to complete various tasks + * + * @module robot_minigame + * @author Koh Wai Kei + * @author Justin Cheng + */ + +export { + init, create_area, create_rect_area, create_obstacle, create_rect_obstacle, complete_init, + 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/robot_minigame/types.ts b/src/bundles/robot_minigame/types.ts new file mode 100644 index 000000000..4c52e27c0 --- /dev/null +++ b/src/bundles/robot_minigame/types.ts @@ -0,0 +1,54 @@ +// 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 +} + +export interface AreaFlags { + [name: string]: any +} + +export interface Area { + vertices: Point[] + isObstacle: boolean + flags: AreaFlags +} + +export interface Test { + type: string + test: Function +} + +export interface AreaTest extends Test { + type: 'area' + test: (areas: Area[]) => boolean +} + +export interface RobotMinigame { + isInit: boolean + hasCollided: boolean + width: number + height: number + robot: Robot + areas: Area[] + areaLog: Area[] + actionLog: Action[] + tests: Test[] + message: string +} diff --git a/src/tabs/RobotMaze/RobotSimulation.tsx b/src/tabs/RobotMaze/RobotSimulation.tsx new file mode 100644 index 000000000..01c0d38bf --- /dev/null +++ b/src/tabs/RobotMaze/RobotSimulation.tsx @@ -0,0 +1,287 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { run_tests } from '../../bundles/robot_minigame/helpers/tests'; +import type { Area, Action, Robot, RobotMinigame } from '../../bundles/robot_minigame/types'; + +/** + * 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 +) => { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, height); + ctx.lineTo(width, height); + ctx.lineTo(width, 0); + ctx.closePath(); + + ctx.strokeStyle = 'gray'; + ctx.lineWidth = 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, + areas: Area[], + robot : Robot +) => { + ctx.reset(); + drawBorders(ctx, width, height); + drawAreas(ctx, areas); + drawRobot(ctx, robot); +}; + +// The speed to move at +const ANIMATION_SPEED : number = 2; + +/** + * React Component props for the Tab. + */ +interface MapProps { + state: RobotMinigame, +} + +const RobotSimulation : React.FC = ({ + state: { + hasCollided, + width, + height, + 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, areas, robot.current); + }, [animationStatus]); + + // Handle animation + useEffect(() => { + if (animationStatus !== 1) return; + + const interval = setInterval(() => { + 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 <= ANIMATION_SPEED) { + // 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) * ANIMATION_SPEED; + robot.current.y += (dy / distance) * ANIMATION_SPEED; + break; + } case 'rotate': + // If rotation is close to target rotation + if (Math.abs((target.rotation - robot.current.rotation) % (2 * Math.PI)) < 0.1) { + // 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 ? 0.1 : -0.1; + + 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, 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 RobotSimulation; diff --git a/src/tabs/RobotMaze/index.tsx b/src/tabs/RobotMaze/index.tsx new file mode 100644 index 000000000..c47977001 --- /dev/null +++ b/src/tabs/RobotMaze/index.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import type { DebuggerContext } from '../../typings/type_helpers'; +import RobotSimulation from './RobotSimulation'; + +/** + * Renders the robot minigame in the assessment workspace + * @author Koh Wai Kei + * @author Justin Cheng + */ + +/** + * React Component props for the Tab. + */ +interface MainProps { + context?: DebuggerContext +} + +/** + * The main React Component of the Tab. + */ +const RobotMaze : React.FC = ({ context }) => { + return ( + + ); +}; + +export default { + /** + * This function will be called to determine if the component will be + * rendered. Currently spawns when the result in the REPL is "test". + * @param {DebuggerContext} context + * @returns {boolean} + */ + toSpawn: (context: DebuggerContext) => context.context?.moduleContexts?.robot_minigame.state.isInit, + + /** + * This function will be called to render the module tab in the side contents + * on Source Academy frontend. + * @param {DebuggerContext} context + */ + body: (context: DebuggerContext) => , + + /** + * The Tab's icon tooltip in the side contents on Source Academy frontend. + */ + label: 'Robot 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: 'build', +};