From 42994643044bee1e10b109d32dca833789762756 Mon Sep 17 00:00:00 2001 From: sayomaki Date: Mon, 5 Feb 2024 20:38:24 +0800 Subject: [PATCH] Resolve line ending issues (#273) * Remove forcing of checkout to CRLF * Fix line endings for files committed with CRLF * Remove linebreak style checking from linter --- .editorconfig | 2 - .eslintrc.base.cjs | 8 +- .gitattributes | 8 - .husky/pre-commit | 8 +- package.json | 272 +-- src/bundles/game/functions.ts | 2236 ++++++++++++------------- src/bundles/game/index.ts | 118 +- src/bundles/game/types.ts | 124 +- src/bundles/repl/config.ts | 20 +- src/bundles/repl/functions.ts | 238 +-- src/bundles/repl/index.ts | 92 +- src/bundles/repl/programmable_repl.ts | 524 +++--- src/tabs/Repl/index.tsx | 358 ++-- 13 files changed, 1999 insertions(+), 2009 deletions(-) delete mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 6d4b8c237..000000000 --- a/.editorconfig +++ /dev/null @@ -1,2 +0,0 @@ -[*] -end_of_line = crlf \ No newline at end of file diff --git a/.eslintrc.base.cjs b/.eslintrc.base.cjs index b919adb4a..0213e319f 100644 --- a/.eslintrc.base.cjs +++ b/.eslintrc.base.cjs @@ -409,10 +409,10 @@ module.exports = { 'key-spacing': 1, 'keyword-spacing': 1, // "line-comment-position": 0, - 'linebreak-style': [ - 1, - 'windows', // Was "unix" - ], + // 'linebreak-style': [ + // 1, + // 'windows', // Was "unix" + // ], // "lines-around-comment": 0, // "lines-between-class-members": 0, // "max-len": 0, diff --git a/.gitattributes b/.gitattributes index 13f38072a..1621c376d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,11 +2,3 @@ ## Src: https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings#per-repository-settings # Set the default behavior, in case people don't have core.autocrlf set. * text=auto - -# Declare files that will always have CRLF line endings on checkout. -*.js text eol=crlf -*.ts text eol=crlf -*.tsx text eol=crlf -*.md text eol=crlf -*.cjs text eol=crlf -*.json text eol=crlf \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index ff9540782..9ee7c1da9 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -yarn test --color +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn test --color diff --git a/package.json b/package.json index 7b8f73f31..289ca538e 100644 --- a/package.json +++ b/package.json @@ -1,136 +1,136 @@ -{ - "private": true, - "name": "modules", - "version": "1.0.0", - "repository": "https://github.com/source-academy/modules.git", - "license": "Apache-2.0", - "scripts-info": { - "//NOTE": "Run `npm i npm-scripts-info -g` to install once globally, then run `npm-scripts-info` as needed to list these descriptions", - "create": "Interactively initialise a new bundle or tab from their templates", - "devserver": "Start the tab development server", - "devserver:lint": "Lint code related to the dev server", - "devserver:tsc": "Run tsc over dev server code", - "docs": "Build only documentation", - "lint": "Lint bundle and tab code", - "build": "Lint code, then build modules and documentation", - "build:help": "Show help for the build scripts", - "serve": "Start the HTTP server to serve all files in `build/`, with the same directory structure", - "scripts": "Run a script within the scripts directory", - "scripts:build": "Compile build scripts", - "scripts:lint": "Lint build script code", - "prepare": "Enable git hooks", - "test": "Run unit tests", - "test:watch": "Watch files for changes and rerun tests related to changed files", - "dev": "Build bundles and tabs only, then serve. Skips linting / type checking, does not build jsons and HTML docs. For rapid testing during development", - "watch": "Watch files for changes and rebuild on those changes", - "postinstall": "Install all patches to node_modules packages" - }, - "type": "module", - "scripts": { - "build": "yarn scripts build", - "build:help": "yarn scripts build --help", - "create": "yarn scripts create", - "dev": "yarn scripts build modules && yarn serve", - "docs": "yarn scripts build docs", - "lint": "yarn scripts lint", - "prepare": "husky install", - "postinstall": "patch-package && yarn scripts:build", - "scripts": "node --max-old-space-size=4096 scripts/bin.js", - "serve": "http-server --cors=* -c-1 -p 8022 ./build", - "test": "yarn scripts test", - "test:all": "yarn test && yarn scripts:test", - "test:watch": "yarn scripts test --watch", - "watch": "yarn scripts watch", - "devserver": "vite", - "devserver:lint": "yarn scripts devserver lint", - "devserver:tsc": "tsc --project devserver/tsconfig.json", - "scripts:all": "node scripts/scripts_manager.js", - "scripts:build": "node scripts/scripts_manager.js build", - "scripts:lint": "node scripts/scripts_manager.js lint", - "scripts:tsc": "tsc --project scripts/src/tsconfig.json", - "scripts:test": "node scripts/scripts_manager.js test" - }, - "devDependencies": { - "@types/dom-mediacapture-record": "^1.0.11", - "@types/eslint": "^8.4.10", - "@types/estree": "^1.0.0", - "@types/jest": "^27.4.1", - "@types/lodash": "^4.14.198", - "@types/node": "^20.8.9", - "@types/plotly.js-dist": "npm:@types/plotly.js", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@typescript-eslint/eslint-plugin": "^6.6.0", - "@typescript-eslint/parser": "^6.6.0", - "@vitejs/plugin-react": "^4.0.4", - "acorn": "^8.8.1", - "acorn-jsx": "^5.3.2", - "astring": "^1.8.4", - "chalk": "^5.0.1", - "commander": "^9.4.0", - "console-table-printer": "^2.11.1", - "cross-env": "^7.0.3", - "esbuild": "^0.18.20", - "eslint": "^8.21.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-import-resolver-typescript": "^2.7.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "^26.8.1", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.29.4", - "eslint-plugin-react-hooks": "^4.4.0", - "eslint-plugin-simple-import-sort": "^8.0.0", - "http-server": "^0.12.3", - "husky": "5", - "jest": "^29.4.1", - "jest-environment-jsdom": "^29.4.1", - "re-resizable": "^6.9.11", - "react-hotkeys": "^2.0.0", - "react-responsive": "^9.0.2", - "sass": "^1.66.1", - "ts-jest": "^29.1.1", - "typedoc": "^0.25.1", - "typescript": "5.0", - "vite": "^4.5.2", - "yarnhook": "^0.5.1" - }, - "dependencies": { - "@blueprintjs/core": "^4.20.2", - "@blueprintjs/icons": "^4.4.0", - "@blueprintjs/popover2": "^1.4.3", - "@box2d/core": "^0.10.0", - "@box2d/debug-draw": "^0.10.0", - "@jscad/modeling": "2.9.6", - "@jscad/regl-renderer": "^2.6.1", - "@jscad/stl-serializer": "^2.1.13", - "ace-builds": "^1.25.1", - "classnames": "^2.3.1", - "dayjs": "^1.10.4", - "gl-matrix": "^3.3.0", - "js-slang": "^1.0.20", - "lodash": "^4.17.21", - "patch-package": "^6.5.1", - "phaser": "^3.54.0", - "plotly.js-dist": "^2.17.1", - "postinstall-postinstall": "^2.1.0", - "react": "^18.2.0", - "react-ace": "^10.1.0", - "react-dom": "^18.2.0", - "regl": "^2.1.0", - "save-file": "^2.3.1", - "source-academy-utils": "^1.0.0", - "source-academy-wabt": "^1.0.4", - "tslib": "^2.3.1" - }, - "jest": { - "projects": [ - "src/jest.config.js", - "scripts/src/jest.config.js" - ] - }, - "resolutions": { - "@types/react": "^18.2.0", - "esbuild": "^0.18.20" - } -} +{ + "private": true, + "name": "modules", + "version": "1.0.0", + "repository": "https://github.com/source-academy/modules.git", + "license": "Apache-2.0", + "scripts-info": { + "//NOTE": "Run `npm i npm-scripts-info -g` to install once globally, then run `npm-scripts-info` as needed to list these descriptions", + "create": "Interactively initialise a new bundle or tab from their templates", + "devserver": "Start the tab development server", + "devserver:lint": "Lint code related to the dev server", + "devserver:tsc": "Run tsc over dev server code", + "docs": "Build only documentation", + "lint": "Lint bundle and tab code", + "build": "Lint code, then build modules and documentation", + "build:help": "Show help for the build scripts", + "serve": "Start the HTTP server to serve all files in `build/`, with the same directory structure", + "scripts": "Run a script within the scripts directory", + "scripts:build": "Compile build scripts", + "scripts:lint": "Lint build script code", + "prepare": "Enable git hooks", + "test": "Run unit tests", + "test:watch": "Watch files for changes and rerun tests related to changed files", + "dev": "Build bundles and tabs only, then serve. Skips linting / type checking, does not build jsons and HTML docs. For rapid testing during development", + "watch": "Watch files for changes and rebuild on those changes", + "postinstall": "Install all patches to node_modules packages" + }, + "type": "module", + "scripts": { + "build": "yarn scripts build", + "build:help": "yarn scripts build --help", + "create": "yarn scripts create", + "dev": "yarn scripts build modules && yarn serve", + "docs": "yarn scripts build docs", + "lint": "yarn scripts lint", + "prepare": "husky install", + "postinstall": "patch-package && yarn scripts:build", + "scripts": "node --max-old-space-size=4096 scripts/bin.js", + "serve": "http-server --cors=* -c-1 -p 8022 ./build", + "test": "yarn scripts test", + "test:all": "yarn test && yarn scripts:test", + "test:watch": "yarn scripts test --watch", + "watch": "yarn scripts watch", + "devserver": "vite", + "devserver:lint": "yarn scripts devserver lint", + "devserver:tsc": "tsc --project devserver/tsconfig.json", + "scripts:all": "node scripts/scripts_manager.js", + "scripts:build": "node scripts/scripts_manager.js build", + "scripts:lint": "node scripts/scripts_manager.js lint", + "scripts:tsc": "tsc --project scripts/src/tsconfig.json", + "scripts:test": "node scripts/scripts_manager.js test" + }, + "devDependencies": { + "@types/dom-mediacapture-record": "^1.0.11", + "@types/eslint": "^8.4.10", + "@types/estree": "^1.0.0", + "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.198", + "@types/node": "^20.8.9", + "@types/plotly.js-dist": "npm:@types/plotly.js", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@typescript-eslint/eslint-plugin": "^6.6.0", + "@typescript-eslint/parser": "^6.6.0", + "@vitejs/plugin-react": "^4.0.4", + "acorn": "^8.8.1", + "acorn-jsx": "^5.3.2", + "astring": "^1.8.4", + "chalk": "^5.0.1", + "commander": "^9.4.0", + "console-table-printer": "^2.11.1", + "cross-env": "^7.0.3", + "esbuild": "^0.18.20", + "eslint": "^8.21.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-import-resolver-typescript": "^2.7.1", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^26.8.1", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.29.4", + "eslint-plugin-react-hooks": "^4.4.0", + "eslint-plugin-simple-import-sort": "^8.0.0", + "http-server": "^0.12.3", + "husky": "5", + "jest": "^29.4.1", + "jest-environment-jsdom": "^29.4.1", + "re-resizable": "^6.9.11", + "react-hotkeys": "^2.0.0", + "react-responsive": "^9.0.2", + "sass": "^1.66.1", + "ts-jest": "^29.1.1", + "typedoc": "^0.25.1", + "typescript": "5.0", + "vite": "^4.5.2", + "yarnhook": "^0.5.1" + }, + "dependencies": { + "@blueprintjs/core": "^4.20.2", + "@blueprintjs/icons": "^4.4.0", + "@blueprintjs/popover2": "^1.4.3", + "@box2d/core": "^0.10.0", + "@box2d/debug-draw": "^0.10.0", + "@jscad/modeling": "2.9.6", + "@jscad/regl-renderer": "^2.6.1", + "@jscad/stl-serializer": "^2.1.13", + "ace-builds": "^1.25.1", + "classnames": "^2.3.1", + "dayjs": "^1.10.4", + "gl-matrix": "^3.3.0", + "js-slang": "^1.0.20", + "lodash": "^4.17.21", + "patch-package": "^6.5.1", + "phaser": "^3.54.0", + "plotly.js-dist": "^2.17.1", + "postinstall-postinstall": "^2.1.0", + "react": "^18.2.0", + "react-ace": "^10.1.0", + "react-dom": "^18.2.0", + "regl": "^2.1.0", + "save-file": "^2.3.1", + "source-academy-utils": "^1.0.0", + "source-academy-wabt": "^1.0.4", + "tslib": "^2.3.1" + }, + "jest": { + "projects": [ + "src/jest.config.js", + "scripts/src/jest.config.js" + ] + }, + "resolutions": { + "@types/react": "^18.2.0", + "esbuild": "^0.18.20" + } +} diff --git a/src/bundles/game/functions.ts b/src/bundles/game/functions.ts index ed7c6cd4b..6a3eae63c 100644 --- a/src/bundles/game/functions.ts +++ b/src/bundles/game/functions.ts @@ -1,1118 +1,1118 @@ -/** - * Game library that translates Phaser 3 API into Source. - * - * More in-depth explanation of the Phaser 3 API can be found at - * Phaser 3 documentation itself. - * - * For Phaser 3 API Documentation, check: - * https://photonstorm.github.io/phaser3-docs/ - * - * @module game - * @author Anthony Halim - * @author Chi Xu - * @author Chong Sia Tiffany - * @author Gokul Rajiv - */ - -/* eslint-disable consistent-return, @typescript-eslint/default-param-last, @typescript-eslint/no-shadow, @typescript-eslint/no-unused-vars */ -import { - type GameObject, - type ObjectConfig, - type RawContainer, - type RawGameElement, - type RawGameObject, - type RawInputObject, - defaultGameParams, -} from './types'; - -import context from 'js-slang/context'; -import { type List, head, tail, is_pair, accumulate } from 'js-slang/dist/stdlib/list'; - -if (!context.moduleContexts.game.state) { - context.moduleContexts.game.state = defaultGameParams; -} - -const { - preloadImageMap, - preloadSoundMap, - preloadSpritesheetMap, - remotePath, - screenSize, - createAward, -} = context.moduleContexts.game.state; - -// Listener ObjectTypes -enum ListenerTypes { - InputPlugin = 'input_plugin', - KeyboardKeyType = 'keyboard_key', -} - -const ListnerTypes = Object.values(ListenerTypes); - -// Object ObjectTypes -enum ObjectTypes { - ImageType = 'image', - TextType = 'text', - RectType = 'rect', - EllipseType = 'ellipse', - ContainerType = 'container', - AwardType = 'award', -} - -const ObjTypes = Object.values(ObjectTypes); - -const nullFn = () => {}; - -const mandatory = (obj, errMsg: string) => { - if (!obj) { - throw_error(errMsg); - } - return obj; -}; - -const scene = () => mandatory(context.moduleContexts.game.state.scene, 'No scene found!'); - -// ============================================================================= -// Module's Private Functions -// ============================================================================= - -/** @hidden */ -function get_obj( - obj: GameObject, -): RawGameObject | RawInputObject | RawContainer { - return obj.object!; -} - -/** @hidden */ -function get_game_obj(obj: GameObject): RawGameObject | RawContainer { - return obj.object as RawGameObject | RawContainer; -} - -/** @hidden */ -function get_input_obj(obj: GameObject): RawInputObject { - return obj.object as RawInputObject; -} - -/** @hidden */ -function get_container(obj: GameObject): RawContainer { - return obj.object as RawContainer; -} - -/** - * Checks whether the given game object is of the enquired type. - * If the given obj is undefined, will also return false. - * - * @param obj the game object - * @param type enquired type - * @returns if game object is of enquired type - * @hidden - */ -function is_type(obj: GameObject, type: string): boolean { - return obj !== undefined && obj.type === type && obj.object !== undefined; -} - -/** - * Checks whether the given game object is any of the enquired ObjectTypes - * - * @param obj the game object - * @param ObjectTypes enquired ObjectTypes - * @returns if game object is of any of the enquired ObjectTypes - * @hidden - */ -function is_any_type(obj: GameObject, types: string[]): boolean { - for (let i = 0; i < types.length; ++i) { - if (is_type(obj, types[i])) return true; - } - return false; -} - -/** - * Set a game object to the given type. - * Mutates the object. - * - * @param object the game object - * @param type type to set - * @returns typed game object - * @hidden - */ -function set_type( - object: RawGameObject | RawInputObject | RawContainer, - type: string, -): GameObject { - return { - type, - object, - }; -} - -/** - * Throw a console error, including the function caller name. - * - * @param {string} message error message - * @hidden - */ -function throw_error(message: string) { - // eslint-disable-next-line no-caller - throw new Error(`${arguments.callee.caller.name}: ${message}`); -} - -// ============================================================================= -// Module's Exposed Functions -// ============================================================================= - -// HELPER - -/** - * Prepend the given asset key with the remote path (S3 path). - * - * @param asset_key - * @returns prepended path - */ -export function prepend_remote_url(asset_key: string): string { - return remotePath(asset_key); -} - -/** - * Transforms the given list of pairs into an object config. The list follows - * the format of list(pair(key1, value1), pair(key2, value2), ...). - * - * e.g list(pair("alpha", 0), pair("duration", 1000)) - * - * @param lst the list to be turned into object config. - * @returns object config - */ -export function create_config(lst: List): ObjectConfig { - const config = {}; - accumulate((xs: [any, any], _) => { - if (!is_pair(xs)) { - throw_error('config element is not a pair!'); - } - config[head(xs)] = tail(xs); - }, null, lst); - return config; -} - -/** - * Create text config object, can be used to stylise text object. - * - * font_family: for available font_family, see: - * https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#Valid_family_names - * - * align: must be either 'left', 'right', 'center', or 'justify' - * - * For more details about text config, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.GameObjects.Text.html#.TextStyle - * - * @param font_family font to be used - * @param font_size size of font, must be appended with 'px' e.g. '16px' - * @param color colour of font, in hex e.g. '#fff' - * @param stroke colour of stroke, in hex e.g. '#fff' - * @param stroke_thickness thickness of stroke - * @param align text alignment - * @returns text config - */ -export function create_text_config( - font_family: string = 'Courier', - font_size: string = '16px', - color: string = '#fff', - stroke: string = '#fff', - stroke_thickness: number = 0, - align: string = 'left', -): ObjectConfig { - return { - fontFamily: font_family, - fontSize: font_size, - color, - stroke, - strokeThickness: stroke_thickness, - align, - }; -} - -/** - * Create interactive config object, can be used to configure interactive settings. - * - * For more details about interactive config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Input.html#.InputConfiguration - * - * @param draggable object will be set draggable - * @param use_hand_cursor if true, pointer will be set to 'pointer' when a pointer is over it - * @param pixel_perfect pixel perfect function will be set for the hit area. Only works for texture based object - * @param alpha_tolerance if pixel_perfect is set, this is the alpha tolerance threshold value used in the callback - * @returns interactive config - */ -export function create_interactive_config( - draggable: boolean = false, - use_hand_cursor: boolean = false, - pixel_perfect: boolean = false, - alpha_tolerance: number = 1, -): ObjectConfig { - return { - draggable, - useHandCursor: use_hand_cursor, - pixelPerfect: pixel_perfect, - alphaTolerance: alpha_tolerance, - }; -} - -/** - * Create sound config object, can be used to configure sound settings. - * - * For more details about sound config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Sound.html#.SoundConfig - * - * @param mute whether the sound should be muted or not - * @param volume value between 0(silence) and 1(full volume) - * @param rate the speed at which the sound is played - * @param detune detuning of the sound, in cents - * @param seek position of playback for the sound, in seconds - * @param loop whether or not the sound should loop - * @param delay time, in seconds, that elapse before the sound actually starts - * @returns sound config - */ -export function create_sound_config( - mute: boolean = false, - volume: number = 1, - rate: number = 1, - detune: number = 0, - seek: number = 0, - loop: boolean = false, - delay: number = 0, -): ObjectConfig { - return { - mute, - volume, - rate, - detune, - seek, - loop, - delay, - }; -} - -/** - * Create tween config object, can be used to configure tween settings. - * - * For more details about tween config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Tweens.html#.TweenBuilderConfig - * - * @param target_prop target to tween, e.g. x, y, alpha - * @param target_value the property value to tween to - * @param delay time in ms/frames before tween will start - * @param duration duration of tween in ms/frames, exclude yoyos or repeats - * @param ease ease function to use, e.g. 'Power0', 'Power1', 'Power2' - * @param on_complete function to execute when tween completes - * @param yoyo if set to true, once tween complete, reverses the values incrementally to get back to the starting tween values - * @param loop number of times the tween should loop, or -1 to loop indefinitely - * @param loop_delay The time the tween will pause before starting either a yoyo or returning to the start for a repeat - * @param on_loop function to execute each time the tween loops - * @returns tween config - */ -export function create_tween_config( - target_prop: string = 'x', - target_value: string | number = 0, - delay: number = 0, - duration: number = 1000, - ease: Function | string = 'Power0', - on_complete: Function = nullFn, - yoyo: boolean = false, - loop: number = 0, - loop_delay: number = 0, - on_loop: Function = nullFn, -): ObjectConfig { - return { - [target_prop]: target_value, - delay, - duration, - ease, - onComplete: on_complete, - yoyo, - loop, - loopDelay: loop_delay, - onLoop: on_loop, - }; -} - -/** - * Create anims config, can be used to configure anims - * - * For more details about the config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Animations.html#.Animation - * - * @param anims_key key that the animation will be associated with - * @param anim_frames data used to generate the frames for animation - * @param frame_rate frame rate of playback in frames per second - * @param duration how long the animation should play in seconds. - * If null, will be derived from frame_rate - * @param repeat number of times to repeat the animation, -1 for infinity - * @param yoyo should the animation yoyo (reverse back down to the start) - * @param show_on_start should the sprite be visible when the anims start? - * @param hide_on_complete should the sprite be not visible when the anims finish? - * @returns animation config - */ -export function create_anim_config( - anims_key: string, - anim_frames: ObjectConfig[], - frame_rate: number = 24, - duration: any = null, - repeat: number = -1, - yoyo: boolean = false, - show_on_start: boolean = true, - hide_on_complete: boolean = false, -): ObjectConfig { - return { - key: anims_key, - frames: anim_frames, - frameRate: frame_rate, - duration, - repeat, - yoyo, - showOnStart: show_on_start, - hideOnComplete: hide_on_complete, - }; -} - -/** - * Create animation frame config, can be used to configure a specific frame - * within an animation. - * - * The key should refer to an image that is already loaded. - * To make frame_config from spritesheet based on its frames, - * use create_anim_spritesheet_frame_configs instead. - * - * @param key key that is associated with the sprite at this frame - * @param duration duration, in ms, of this frame of the animation - * @param visible should the parent object be visible during this frame? - * @returns animation frame config - */ -export function create_anim_frame_config( - key: string, - duration: number = 0, - visible: boolean = true, -): ObjectConfig { - return { - key, - duration, - visible, - }; -} - -/** - * Create list of animation frame config, can be used directly as part of - * anim_config's `frames` parameter. - * - * This function will generate list of frame configs based on the - * spritesheet_config attached to the associated spritesheet. - * This function requires that the given key is a spritesheet key - * i.e. a key associated with loaded spritesheet, loaded in using - * load_spritesheet function. - * - * Will return empty frame configs if key is not associated with - * a spritesheet. - * - * @param key key associated with spritesheet - * @returns animation frame configs - */ -export function create_anim_spritesheet_frame_configs( - key: string, -): ObjectConfig[] | undefined { - if (preloadSpritesheetMap.get(key)) { - const configArr = scene().anims.generateFrameNumbers(key, {}); - return configArr; - } - throw_error(`${key} is not associated with any spritesheet`); -} - -/** - * Create spritesheet config, can be used to configure the frames within the - * spritesheet. Can be used as config at load_spritesheet. - * - * @param frame_width width of frame in pixels - * @param frame_height height of frame in pixels - * @param start_frame first frame to start parsing from - * @param margin margin in the image; this is the space around the edge of the frames - * @param spacing the spacing between each frame in the image - * @returns spritesheet config - */ -export function create_spritesheet_config( - frame_width: number, - frame_height: number, - start_frame: number = 0, - margin: number = 0, - spacing: number = 0, -): ObjectConfig { - return { - frameWidth: frame_width, - frameHeight: frame_height, - startFrame: start_frame, - margin, - spacing, - }; -} - -// SCREEN - -/** - * Get in-game screen width. - * - * @return screen width - */ -export function get_screen_width(): number { - return screenSize.x; -} - -/** - * Get in-game screen height. - * - * @return screen height - */ -export function get_screen_height(): number { - return screenSize.y; -} - -/** - * Get game screen display width (accounting window size). - * - * @return screen display width - */ -export function get_screen_display_width(): number { - return scene().scale.displaySize.width; -} - -/** - * Get game screen display height (accounting window size). - * - * @return screen display height - */ -export function get_screen_display_height(): number { - return scene().scale.displaySize.height; -} - -// LOAD - -/** - * Load the image asset into the scene for use. All images - * must be loaded before used in create_image. - * - * @param key key to be associated with the image - * @param url path to the image - */ -export function load_image(key: string, url: string) { - preloadImageMap.set(key, url); -} - -/** - * Load the sound asset into the scene for use. All sound - * must be loaded before used in play_sound. - * - * @param key key to be associated with the sound - * @param url path to the sound - */ -export function load_sound(key: string, url: string) { - preloadSoundMap.set(key, url); -} - -/** - * Load the spritesheet into the scene for use. All spritesheet must - * be loaded before used in create_image. - * - * @param key key associated with the spritesheet - * @param url path to the sound - * @param spritesheet_config config to determines frames within the spritesheet - */ -export function load_spritesheet( - key: string, - url: string, - spritesheet_config: ObjectConfig, -) { - preloadSpritesheetMap.set(key, [url, spritesheet_config]); -} - -// ADD - -/** - * Add the object to the scene. Only objects added to the scene - * will appear. - * - * @param obj game object to be added - */ -export function add(obj: GameObject): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - scene().add.existing(get_game_obj(obj)); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -// SOUND - -/** - * Play the sound associated with the key. - * Throws error if key is non-existent. - * - * @param key key to the sound to be played - * @param config sound config to be used - */ -export function play_sound(key: string, config: ObjectConfig = {}): void { - if (preloadSoundMap.get(key)) { - scene().sound.play(key, config); - } else { - throw_error(`${key} is not associated with any sound`); - } -} - -// ANIMS - -/** - * Create a new animation and add it to the available animations. - * Animations are global i.e. once created, it can be used anytime, anywhere. - * - * NOTE: Anims DO NOT need to be added into the scene to be used. - * It is automatically added to the scene when it is created. - * - * Will return true if the animation key is valid - * (key is specified within the anim_config); false if the key - * is already in use. - * - * @param anim_config - * @returns true if animation is successfully created, false otherwise - */ -export function create_anim(anim_config: ObjectConfig): boolean { - const anims = scene().anims.create(anim_config); - return typeof anims !== 'boolean'; -} - -/** - * Start playing the given animation on image game object. - * - * @param image image game object - * @param anims_key key associated with an animation - */ -export function play_anim_on_image( - image: GameObject, - anims_key: string, -): GameObject | undefined { - if (is_type(image, ObjectTypes.ImageType)) { - (get_obj(image) as Phaser.GameObjects.Sprite).play(anims_key); - return image; - } - throw_error(`${image} is not of type ${ObjectTypes.ImageType}`); -} - -// IMAGE - -/** - * Create an image using the key associated with a loaded image. - * If key is not associated with any loaded image, throws error. - * - * 0, 0 is located at the top, left hand side. - * - * @param x x position of the image. 0 is at the left side - * @param y y position of the image. 0 is at the top side - * @param asset_key key to loaded image - * @returns image game object - */ -export function create_image( - x: number, - y: number, - asset_key: string, -): GameObject | undefined { - if ( - preloadImageMap.get(asset_key) - || preloadSpritesheetMap.get(asset_key) - ) { - const image = new Phaser.GameObjects.Sprite(scene(), x, y, asset_key); - return set_type(image, ObjectTypes.ImageType); - } - throw_error(`${asset_key} is not associated with any image`); -} - -// AWARD - -/** - * Create an award using the key associated with the award. - * The award key can be obtained from the Awards Hall or - * Awards menu, after attaining the award. - * - * Valid award will have an on-hover VERIFIED tag to distinguish - * it from images created by create_image. - * - * If student does not possess the award, this function will - * return a untagged, default image. - * - * @param x x position of the image. 0 is at the left side - * @param y y position of the image. 0 is at the top side - * @param award_key key for award - * @returns award game object - */ -export function create_award(x: number, y: number, award_key: string): GameObject { - return set_type(createAward(x, y, award_key), ObjectTypes.AwardType); -} - -// TEXT - -/** - * Create a text object. - * - * 0, 0 is located at the top, left hand side. - * - * @param x x position of the text - * @param y y position of the text - * @param text text to be shown - * @param config text configuration to be used - * @returns text game object - */ -export function create_text( - x: number, - y: number, - text: string, - config: ObjectConfig = {}, -): GameObject { - const txt = new Phaser.GameObjects.Text(scene(), x, y, text, config); - return set_type(txt, ObjectTypes.TextType); -} - -// RECTANGLE - -/** - * Create a rectangle object. - * - * 0, 0 is located at the top, left hand side. - * - * @param x x coordinate of the top, left corner posiiton - * @param y y coordinate of the top, left corner position - * @param width width of rectangle - * @param height height of rectangle - * @param fill colour fill, in hext e.g 0xffffff - * @param alpha value between 0 and 1 to denote alpha - * @returns rectangle object - */ -export function create_rect( - x: number, - y: number, - width: number, - height: number, - fill: number = 0, - alpha: number = 1, -): GameObject { - const rect = new Phaser.GameObjects.Rectangle( - scene(), - x, - y, - width, - height, - fill, - alpha, - ); - return set_type(rect, ObjectTypes.RectType); -} - -// ELLIPSE - -/** - * Create an ellipse object. - * - * @param x x coordinate of the centre of ellipse - * @param y y coordinate of the centre of ellipse - * @param width width of ellipse - * @param height height of ellipse - * @param fill colour fill, in hext e.g 0xffffff - * @param alpha value between 0 and 1 to denote alpha - * @returns ellipse object - */ -export function create_ellipse( - x: number, - y: number, - width: number, - height: number, - fill: number = 0, - alpha: number = 1, -): GameObject { - const ellipse = new Phaser.GameObjects.Ellipse( - scene(), - x, - y, - width, - height, - fill, - alpha, - ); - return set_type(ellipse, ObjectTypes.EllipseType); -} - -// CONTAINER - -/** - * Create a container object. Container is able to contain any other game object, - * and the positions of contained game object will be relative to the container. - * - * Rendering the container as visible or invisible will also affect the contained - * game object. - * - * Container can also contain another container. - * - * 0, 0 is located at the top, left hand side. - * - * For more details about container object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.GameObjects.Container.html - * - * @param x x position of the container - * @param y y position of the container - * @returns container object - */ -export function create_container(x: number, y: number): GameObject { - const cont = new Phaser.GameObjects.Container(scene(), x, y); - return set_type(cont, ObjectTypes.ContainerType); -} - -/** - * Add the given game object to the container. - * Mutates the container. - * - * @param container container object - * @param obj game object to add to the container - * @returns container object - */ -export function add_to_container( - container: GameObject, - obj: GameObject, -): GameObject | undefined { - if ( - is_type(container, ObjectTypes.ContainerType) - && is_any_type(obj, ObjTypes) - ) { - get_container(container) - .add(get_game_obj(obj)); - return container; - } - throw_error( - `${obj} is not of type ${ObjTypes} or ${container} is not of type ${ObjectTypes.ContainerType}`, - ); -} - -// OBJECT - -/** - * Destroy the given game object. Destroyed game object - * is removed from the scene, and all of its listeners - * is also removed. - * - * @param obj game object itself - */ -export function destroy_obj(obj: GameObject) { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .destroy(); - } else { - throw_error(`${obj} is not of type ${ObjTypes}`); - } -} - -/** - * Set the display size of the object. - * Mutate the object. - * - * @param obj object to be set - * @param x new display width size - * @param y new display height size - * @returns game object itself - */ -export function set_display_size( - obj: GameObject, - x: number, - y: number, -): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .setDisplaySize(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Set the alpha of the object. - * Mutate the object. - * - * @param obj object to be set - * @param alpha new alpha - * @returns game object itself - */ -export function set_alpha(obj: GameObject, alpha: number): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .setAlpha(alpha); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Set the interactivity of the object. - * Mutate the object. - * - * Rectangle and Ellipse are not able to receive configs, only boolean - * i.e. set_interactive(rect, true); set_interactive(ellipse, false) - * - * @param obj object to be set - * @param config interactive config to be used - * @returns game object itself - */ -export function set_interactive( - obj: GameObject, - config: ObjectConfig = {}, -): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .setInteractive(config); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Set the origin in which all position related will be relative to. - * In other words, the anchor of the object. - * Mutate the object. - * - * @param obj object to be set - * @param x new anchor x coordinate, between value 0 to 1. - * @param y new anchor y coordinate, between value 0 to 1. - * @returns game object itself - */ -export function set_origin( - obj: GameObject, - x: number, - y: number, -): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - (get_game_obj(obj) as RawGameObject).setOrigin(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Set the position of the game object - * Mutate the object - * - * @param obj object to be set - * @param x new x position - * @param y new y position - * @returns game object itself - */ -export function set_position( - obj: GameObject, - x: number, - y: number, -): GameObject | undefined { - if (obj && is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .setPosition(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Set the scale of the object. - * Mutate the object. - * - * @param obj object to be set - * @param x new x scale - * @param y new y scale - * @returns game object itself - */ -export function set_scale( - obj: GameObject, - x: number, - y: number, -): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .setScale(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Set the rotation of the object. - * Mutate the object. - * - * @param obj object to be set - * @param rad the rotation, in radians - * @returns game object itself - */ -export function set_rotation(obj: GameObject, rad: number): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj) - .setRotation(rad); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Sets the horizontal and flipped state of the object. - * Mutate the object. - * - * @param obj game object itself - * @param x to flip in the horizontal state - * @param y to flip in the vertical state - * @returns game object itself - */ -export function set_flip( - obj: GameObject, - x: boolean, - y: boolean, -): GameObject | undefined { - const GameElementType = [ObjectTypes.ImageType, ObjectTypes.TextType]; - if (is_any_type(obj, GameElementType)) { - (get_obj(obj) as RawGameElement).setFlip(x, y); - return obj; - } - throw_error(`${obj} is not of type ${GameElementType}`); -} - -/** - * Creates a tween to the object and plays it. - * Mutate the object. - * - * @param obj object to be added to - * @param config tween config - * @returns game object itself - */ -export async function add_tween( - obj: GameObject, - config: ObjectConfig = {}, -): Promise { - if (is_any_type(obj, ObjTypes)) { - scene().tweens.add({ - targets: get_game_obj(obj), - ...config, - }); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -// LISTENER - -/** - * Attach a listener to the object. The callback will be executed - * when the event is emitted. - * Mutate the object. - * - * For all available events, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html - * - * @param obj object to be added to - * @param event the event name - * @param callback listener function, executed on event - * @returns listener game object - */ -export function add_listener( - obj: GameObject, - event: string, - callback: Function, -): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - const listener = get_game_obj(obj) - .addListener(event, callback); - return set_type(listener, ListenerTypes.InputPlugin); - } - throw_error(`${obj} is not of type ${ObjTypes}`); -} - -/** - * Attach a listener to the object. The callback will be executed - * when the event is emitted. - * Mutate the object. - * - * For all available events, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html - * - * For list of keycodes, see: - * https://github.com/photonstorm/phaser/blob/v3.22.0/src/input/keyboard/keys/KeyCodes.js - * - * @param key keyboard key to trigger listener - * @param event the event name - * @param callback listener function, executed on event - * @returns listener game object - */ -export function add_keyboard_listener( - key: string | number, - event: string, - callback: Function, -): GameObject { - const keyObj = scene().input.keyboard.addKey(key); - const keyboardListener = keyObj.addListener(event, callback); - return set_type(keyboardListener, ListenerTypes.KeyboardKeyType); -} - -/** - * Deactivate and remove listener. - * - * @param listener - * @returns if successful - */ -export function remove_listener(listener: GameObject): boolean { - if (is_any_type(listener, ListnerTypes)) { - get_input_obj(listener) - .removeAllListeners(); - return true; - } - return false; -} - -const gameFunctions = [ - add, - add_listener, - add_keyboard_listener, - add_to_container, - add_tween, - create_anim, - create_anim_config, - create_anim_frame_config, - create_anim_spritesheet_frame_configs, - create_award, - create_config, - create_container, - create_ellipse, - create_image, - create_interactive_config, - create_rect, - create_text, - create_text_config, - create_tween_config, - create_sound_config, - create_spritesheet_config, - destroy_obj, - get_screen_width, - get_screen_height, - get_screen_display_width, - get_screen_display_height, - load_image, - load_sound, - load_spritesheet, - play_anim_on_image, - play_sound, - prepend_remote_url, - remove_listener, - set_alpha, - set_display_size, - set_flip, - set_interactive, - set_origin, - set_position, - set_rotation, - set_scale, -]; - -// Inject minArgsNeeded to allow module varargs -// Remove if module varargs is fixed on js-slang side -gameFunctions.forEach((fn) => { - const dummy = fn as any; - dummy.minArgsNeeded = fn.length; -}); +/** + * Game library that translates Phaser 3 API into Source. + * + * More in-depth explanation of the Phaser 3 API can be found at + * Phaser 3 documentation itself. + * + * For Phaser 3 API Documentation, check: + * https://photonstorm.github.io/phaser3-docs/ + * + * @module game + * @author Anthony Halim + * @author Chi Xu + * @author Chong Sia Tiffany + * @author Gokul Rajiv + */ + +/* eslint-disable consistent-return, @typescript-eslint/default-param-last, @typescript-eslint/no-shadow, @typescript-eslint/no-unused-vars */ +import { + type GameObject, + type ObjectConfig, + type RawContainer, + type RawGameElement, + type RawGameObject, + type RawInputObject, + defaultGameParams, +} from './types'; + +import context from 'js-slang/context'; +import { type List, head, tail, is_pair, accumulate } from 'js-slang/dist/stdlib/list'; + +if (!context.moduleContexts.game.state) { + context.moduleContexts.game.state = defaultGameParams; +} + +const { + preloadImageMap, + preloadSoundMap, + preloadSpritesheetMap, + remotePath, + screenSize, + createAward, +} = context.moduleContexts.game.state; + +// Listener ObjectTypes +enum ListenerTypes { + InputPlugin = 'input_plugin', + KeyboardKeyType = 'keyboard_key', +} + +const ListnerTypes = Object.values(ListenerTypes); + +// Object ObjectTypes +enum ObjectTypes { + ImageType = 'image', + TextType = 'text', + RectType = 'rect', + EllipseType = 'ellipse', + ContainerType = 'container', + AwardType = 'award', +} + +const ObjTypes = Object.values(ObjectTypes); + +const nullFn = () => {}; + +const mandatory = (obj, errMsg: string) => { + if (!obj) { + throw_error(errMsg); + } + return obj; +}; + +const scene = () => mandatory(context.moduleContexts.game.state.scene, 'No scene found!'); + +// ============================================================================= +// Module's Private Functions +// ============================================================================= + +/** @hidden */ +function get_obj( + obj: GameObject, +): RawGameObject | RawInputObject | RawContainer { + return obj.object!; +} + +/** @hidden */ +function get_game_obj(obj: GameObject): RawGameObject | RawContainer { + return obj.object as RawGameObject | RawContainer; +} + +/** @hidden */ +function get_input_obj(obj: GameObject): RawInputObject { + return obj.object as RawInputObject; +} + +/** @hidden */ +function get_container(obj: GameObject): RawContainer { + return obj.object as RawContainer; +} + +/** + * Checks whether the given game object is of the enquired type. + * If the given obj is undefined, will also return false. + * + * @param obj the game object + * @param type enquired type + * @returns if game object is of enquired type + * @hidden + */ +function is_type(obj: GameObject, type: string): boolean { + return obj !== undefined && obj.type === type && obj.object !== undefined; +} + +/** + * Checks whether the given game object is any of the enquired ObjectTypes + * + * @param obj the game object + * @param ObjectTypes enquired ObjectTypes + * @returns if game object is of any of the enquired ObjectTypes + * @hidden + */ +function is_any_type(obj: GameObject, types: string[]): boolean { + for (let i = 0; i < types.length; ++i) { + if (is_type(obj, types[i])) return true; + } + return false; +} + +/** + * Set a game object to the given type. + * Mutates the object. + * + * @param object the game object + * @param type type to set + * @returns typed game object + * @hidden + */ +function set_type( + object: RawGameObject | RawInputObject | RawContainer, + type: string, +): GameObject { + return { + type, + object, + }; +} + +/** + * Throw a console error, including the function caller name. + * + * @param {string} message error message + * @hidden + */ +function throw_error(message: string) { + // eslint-disable-next-line no-caller + throw new Error(`${arguments.callee.caller.name}: ${message}`); +} + +// ============================================================================= +// Module's Exposed Functions +// ============================================================================= + +// HELPER + +/** + * Prepend the given asset key with the remote path (S3 path). + * + * @param asset_key + * @returns prepended path + */ +export function prepend_remote_url(asset_key: string): string { + return remotePath(asset_key); +} + +/** + * Transforms the given list of pairs into an object config. The list follows + * the format of list(pair(key1, value1), pair(key2, value2), ...). + * + * e.g list(pair("alpha", 0), pair("duration", 1000)) + * + * @param lst the list to be turned into object config. + * @returns object config + */ +export function create_config(lst: List): ObjectConfig { + const config = {}; + accumulate((xs: [any, any], _) => { + if (!is_pair(xs)) { + throw_error('config element is not a pair!'); + } + config[head(xs)] = tail(xs); + }, null, lst); + return config; +} + +/** + * Create text config object, can be used to stylise text object. + * + * font_family: for available font_family, see: + * https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#Valid_family_names + * + * align: must be either 'left', 'right', 'center', or 'justify' + * + * For more details about text config, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.GameObjects.Text.html#.TextStyle + * + * @param font_family font to be used + * @param font_size size of font, must be appended with 'px' e.g. '16px' + * @param color colour of font, in hex e.g. '#fff' + * @param stroke colour of stroke, in hex e.g. '#fff' + * @param stroke_thickness thickness of stroke + * @param align text alignment + * @returns text config + */ +export function create_text_config( + font_family: string = 'Courier', + font_size: string = '16px', + color: string = '#fff', + stroke: string = '#fff', + stroke_thickness: number = 0, + align: string = 'left', +): ObjectConfig { + return { + fontFamily: font_family, + fontSize: font_size, + color, + stroke, + strokeThickness: stroke_thickness, + align, + }; +} + +/** + * Create interactive config object, can be used to configure interactive settings. + * + * For more details about interactive config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Input.html#.InputConfiguration + * + * @param draggable object will be set draggable + * @param use_hand_cursor if true, pointer will be set to 'pointer' when a pointer is over it + * @param pixel_perfect pixel perfect function will be set for the hit area. Only works for texture based object + * @param alpha_tolerance if pixel_perfect is set, this is the alpha tolerance threshold value used in the callback + * @returns interactive config + */ +export function create_interactive_config( + draggable: boolean = false, + use_hand_cursor: boolean = false, + pixel_perfect: boolean = false, + alpha_tolerance: number = 1, +): ObjectConfig { + return { + draggable, + useHandCursor: use_hand_cursor, + pixelPerfect: pixel_perfect, + alphaTolerance: alpha_tolerance, + }; +} + +/** + * Create sound config object, can be used to configure sound settings. + * + * For more details about sound config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Sound.html#.SoundConfig + * + * @param mute whether the sound should be muted or not + * @param volume value between 0(silence) and 1(full volume) + * @param rate the speed at which the sound is played + * @param detune detuning of the sound, in cents + * @param seek position of playback for the sound, in seconds + * @param loop whether or not the sound should loop + * @param delay time, in seconds, that elapse before the sound actually starts + * @returns sound config + */ +export function create_sound_config( + mute: boolean = false, + volume: number = 1, + rate: number = 1, + detune: number = 0, + seek: number = 0, + loop: boolean = false, + delay: number = 0, +): ObjectConfig { + return { + mute, + volume, + rate, + detune, + seek, + loop, + delay, + }; +} + +/** + * Create tween config object, can be used to configure tween settings. + * + * For more details about tween config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Tweens.html#.TweenBuilderConfig + * + * @param target_prop target to tween, e.g. x, y, alpha + * @param target_value the property value to tween to + * @param delay time in ms/frames before tween will start + * @param duration duration of tween in ms/frames, exclude yoyos or repeats + * @param ease ease function to use, e.g. 'Power0', 'Power1', 'Power2' + * @param on_complete function to execute when tween completes + * @param yoyo if set to true, once tween complete, reverses the values incrementally to get back to the starting tween values + * @param loop number of times the tween should loop, or -1 to loop indefinitely + * @param loop_delay The time the tween will pause before starting either a yoyo or returning to the start for a repeat + * @param on_loop function to execute each time the tween loops + * @returns tween config + */ +export function create_tween_config( + target_prop: string = 'x', + target_value: string | number = 0, + delay: number = 0, + duration: number = 1000, + ease: Function | string = 'Power0', + on_complete: Function = nullFn, + yoyo: boolean = false, + loop: number = 0, + loop_delay: number = 0, + on_loop: Function = nullFn, +): ObjectConfig { + return { + [target_prop]: target_value, + delay, + duration, + ease, + onComplete: on_complete, + yoyo, + loop, + loopDelay: loop_delay, + onLoop: on_loop, + }; +} + +/** + * Create anims config, can be used to configure anims + * + * For more details about the config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Animations.html#.Animation + * + * @param anims_key key that the animation will be associated with + * @param anim_frames data used to generate the frames for animation + * @param frame_rate frame rate of playback in frames per second + * @param duration how long the animation should play in seconds. + * If null, will be derived from frame_rate + * @param repeat number of times to repeat the animation, -1 for infinity + * @param yoyo should the animation yoyo (reverse back down to the start) + * @param show_on_start should the sprite be visible when the anims start? + * @param hide_on_complete should the sprite be not visible when the anims finish? + * @returns animation config + */ +export function create_anim_config( + anims_key: string, + anim_frames: ObjectConfig[], + frame_rate: number = 24, + duration: any = null, + repeat: number = -1, + yoyo: boolean = false, + show_on_start: boolean = true, + hide_on_complete: boolean = false, +): ObjectConfig { + return { + key: anims_key, + frames: anim_frames, + frameRate: frame_rate, + duration, + repeat, + yoyo, + showOnStart: show_on_start, + hideOnComplete: hide_on_complete, + }; +} + +/** + * Create animation frame config, can be used to configure a specific frame + * within an animation. + * + * The key should refer to an image that is already loaded. + * To make frame_config from spritesheet based on its frames, + * use create_anim_spritesheet_frame_configs instead. + * + * @param key key that is associated with the sprite at this frame + * @param duration duration, in ms, of this frame of the animation + * @param visible should the parent object be visible during this frame? + * @returns animation frame config + */ +export function create_anim_frame_config( + key: string, + duration: number = 0, + visible: boolean = true, +): ObjectConfig { + return { + key, + duration, + visible, + }; +} + +/** + * Create list of animation frame config, can be used directly as part of + * anim_config's `frames` parameter. + * + * This function will generate list of frame configs based on the + * spritesheet_config attached to the associated spritesheet. + * This function requires that the given key is a spritesheet key + * i.e. a key associated with loaded spritesheet, loaded in using + * load_spritesheet function. + * + * Will return empty frame configs if key is not associated with + * a spritesheet. + * + * @param key key associated with spritesheet + * @returns animation frame configs + */ +export function create_anim_spritesheet_frame_configs( + key: string, +): ObjectConfig[] | undefined { + if (preloadSpritesheetMap.get(key)) { + const configArr = scene().anims.generateFrameNumbers(key, {}); + return configArr; + } + throw_error(`${key} is not associated with any spritesheet`); +} + +/** + * Create spritesheet config, can be used to configure the frames within the + * spritesheet. Can be used as config at load_spritesheet. + * + * @param frame_width width of frame in pixels + * @param frame_height height of frame in pixels + * @param start_frame first frame to start parsing from + * @param margin margin in the image; this is the space around the edge of the frames + * @param spacing the spacing between each frame in the image + * @returns spritesheet config + */ +export function create_spritesheet_config( + frame_width: number, + frame_height: number, + start_frame: number = 0, + margin: number = 0, + spacing: number = 0, +): ObjectConfig { + return { + frameWidth: frame_width, + frameHeight: frame_height, + startFrame: start_frame, + margin, + spacing, + }; +} + +// SCREEN + +/** + * Get in-game screen width. + * + * @return screen width + */ +export function get_screen_width(): number { + return screenSize.x; +} + +/** + * Get in-game screen height. + * + * @return screen height + */ +export function get_screen_height(): number { + return screenSize.y; +} + +/** + * Get game screen display width (accounting window size). + * + * @return screen display width + */ +export function get_screen_display_width(): number { + return scene().scale.displaySize.width; +} + +/** + * Get game screen display height (accounting window size). + * + * @return screen display height + */ +export function get_screen_display_height(): number { + return scene().scale.displaySize.height; +} + +// LOAD + +/** + * Load the image asset into the scene for use. All images + * must be loaded before used in create_image. + * + * @param key key to be associated with the image + * @param url path to the image + */ +export function load_image(key: string, url: string) { + preloadImageMap.set(key, url); +} + +/** + * Load the sound asset into the scene for use. All sound + * must be loaded before used in play_sound. + * + * @param key key to be associated with the sound + * @param url path to the sound + */ +export function load_sound(key: string, url: string) { + preloadSoundMap.set(key, url); +} + +/** + * Load the spritesheet into the scene for use. All spritesheet must + * be loaded before used in create_image. + * + * @param key key associated with the spritesheet + * @param url path to the sound + * @param spritesheet_config config to determines frames within the spritesheet + */ +export function load_spritesheet( + key: string, + url: string, + spritesheet_config: ObjectConfig, +) { + preloadSpritesheetMap.set(key, [url, spritesheet_config]); +} + +// ADD + +/** + * Add the object to the scene. Only objects added to the scene + * will appear. + * + * @param obj game object to be added + */ +export function add(obj: GameObject): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + scene().add.existing(get_game_obj(obj)); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +// SOUND + +/** + * Play the sound associated with the key. + * Throws error if key is non-existent. + * + * @param key key to the sound to be played + * @param config sound config to be used + */ +export function play_sound(key: string, config: ObjectConfig = {}): void { + if (preloadSoundMap.get(key)) { + scene().sound.play(key, config); + } else { + throw_error(`${key} is not associated with any sound`); + } +} + +// ANIMS + +/** + * Create a new animation and add it to the available animations. + * Animations are global i.e. once created, it can be used anytime, anywhere. + * + * NOTE: Anims DO NOT need to be added into the scene to be used. + * It is automatically added to the scene when it is created. + * + * Will return true if the animation key is valid + * (key is specified within the anim_config); false if the key + * is already in use. + * + * @param anim_config + * @returns true if animation is successfully created, false otherwise + */ +export function create_anim(anim_config: ObjectConfig): boolean { + const anims = scene().anims.create(anim_config); + return typeof anims !== 'boolean'; +} + +/** + * Start playing the given animation on image game object. + * + * @param image image game object + * @param anims_key key associated with an animation + */ +export function play_anim_on_image( + image: GameObject, + anims_key: string, +): GameObject | undefined { + if (is_type(image, ObjectTypes.ImageType)) { + (get_obj(image) as Phaser.GameObjects.Sprite).play(anims_key); + return image; + } + throw_error(`${image} is not of type ${ObjectTypes.ImageType}`); +} + +// IMAGE + +/** + * Create an image using the key associated with a loaded image. + * If key is not associated with any loaded image, throws error. + * + * 0, 0 is located at the top, left hand side. + * + * @param x x position of the image. 0 is at the left side + * @param y y position of the image. 0 is at the top side + * @param asset_key key to loaded image + * @returns image game object + */ +export function create_image( + x: number, + y: number, + asset_key: string, +): GameObject | undefined { + if ( + preloadImageMap.get(asset_key) + || preloadSpritesheetMap.get(asset_key) + ) { + const image = new Phaser.GameObjects.Sprite(scene(), x, y, asset_key); + return set_type(image, ObjectTypes.ImageType); + } + throw_error(`${asset_key} is not associated with any image`); +} + +// AWARD + +/** + * Create an award using the key associated with the award. + * The award key can be obtained from the Awards Hall or + * Awards menu, after attaining the award. + * + * Valid award will have an on-hover VERIFIED tag to distinguish + * it from images created by create_image. + * + * If student does not possess the award, this function will + * return a untagged, default image. + * + * @param x x position of the image. 0 is at the left side + * @param y y position of the image. 0 is at the top side + * @param award_key key for award + * @returns award game object + */ +export function create_award(x: number, y: number, award_key: string): GameObject { + return set_type(createAward(x, y, award_key), ObjectTypes.AwardType); +} + +// TEXT + +/** + * Create a text object. + * + * 0, 0 is located at the top, left hand side. + * + * @param x x position of the text + * @param y y position of the text + * @param text text to be shown + * @param config text configuration to be used + * @returns text game object + */ +export function create_text( + x: number, + y: number, + text: string, + config: ObjectConfig = {}, +): GameObject { + const txt = new Phaser.GameObjects.Text(scene(), x, y, text, config); + return set_type(txt, ObjectTypes.TextType); +} + +// RECTANGLE + +/** + * Create a rectangle object. + * + * 0, 0 is located at the top, left hand side. + * + * @param x x coordinate of the top, left corner posiiton + * @param y y coordinate of the top, left corner position + * @param width width of rectangle + * @param height height of rectangle + * @param fill colour fill, in hext e.g 0xffffff + * @param alpha value between 0 and 1 to denote alpha + * @returns rectangle object + */ +export function create_rect( + x: number, + y: number, + width: number, + height: number, + fill: number = 0, + alpha: number = 1, +): GameObject { + const rect = new Phaser.GameObjects.Rectangle( + scene(), + x, + y, + width, + height, + fill, + alpha, + ); + return set_type(rect, ObjectTypes.RectType); +} + +// ELLIPSE + +/** + * Create an ellipse object. + * + * @param x x coordinate of the centre of ellipse + * @param y y coordinate of the centre of ellipse + * @param width width of ellipse + * @param height height of ellipse + * @param fill colour fill, in hext e.g 0xffffff + * @param alpha value between 0 and 1 to denote alpha + * @returns ellipse object + */ +export function create_ellipse( + x: number, + y: number, + width: number, + height: number, + fill: number = 0, + alpha: number = 1, +): GameObject { + const ellipse = new Phaser.GameObjects.Ellipse( + scene(), + x, + y, + width, + height, + fill, + alpha, + ); + return set_type(ellipse, ObjectTypes.EllipseType); +} + +// CONTAINER + +/** + * Create a container object. Container is able to contain any other game object, + * and the positions of contained game object will be relative to the container. + * + * Rendering the container as visible or invisible will also affect the contained + * game object. + * + * Container can also contain another container. + * + * 0, 0 is located at the top, left hand side. + * + * For more details about container object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.GameObjects.Container.html + * + * @param x x position of the container + * @param y y position of the container + * @returns container object + */ +export function create_container(x: number, y: number): GameObject { + const cont = new Phaser.GameObjects.Container(scene(), x, y); + return set_type(cont, ObjectTypes.ContainerType); +} + +/** + * Add the given game object to the container. + * Mutates the container. + * + * @param container container object + * @param obj game object to add to the container + * @returns container object + */ +export function add_to_container( + container: GameObject, + obj: GameObject, +): GameObject | undefined { + if ( + is_type(container, ObjectTypes.ContainerType) + && is_any_type(obj, ObjTypes) + ) { + get_container(container) + .add(get_game_obj(obj)); + return container; + } + throw_error( + `${obj} is not of type ${ObjTypes} or ${container} is not of type ${ObjectTypes.ContainerType}`, + ); +} + +// OBJECT + +/** + * Destroy the given game object. Destroyed game object + * is removed from the scene, and all of its listeners + * is also removed. + * + * @param obj game object itself + */ +export function destroy_obj(obj: GameObject) { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .destroy(); + } else { + throw_error(`${obj} is not of type ${ObjTypes}`); + } +} + +/** + * Set the display size of the object. + * Mutate the object. + * + * @param obj object to be set + * @param x new display width size + * @param y new display height size + * @returns game object itself + */ +export function set_display_size( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setDisplaySize(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Set the alpha of the object. + * Mutate the object. + * + * @param obj object to be set + * @param alpha new alpha + * @returns game object itself + */ +export function set_alpha(obj: GameObject, alpha: number): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setAlpha(alpha); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Set the interactivity of the object. + * Mutate the object. + * + * Rectangle and Ellipse are not able to receive configs, only boolean + * i.e. set_interactive(rect, true); set_interactive(ellipse, false) + * + * @param obj object to be set + * @param config interactive config to be used + * @returns game object itself + */ +export function set_interactive( + obj: GameObject, + config: ObjectConfig = {}, +): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setInteractive(config); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Set the origin in which all position related will be relative to. + * In other words, the anchor of the object. + * Mutate the object. + * + * @param obj object to be set + * @param x new anchor x coordinate, between value 0 to 1. + * @param y new anchor y coordinate, between value 0 to 1. + * @returns game object itself + */ +export function set_origin( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + (get_game_obj(obj) as RawGameObject).setOrigin(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Set the position of the game object + * Mutate the object + * + * @param obj object to be set + * @param x new x position + * @param y new y position + * @returns game object itself + */ +export function set_position( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + if (obj && is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setPosition(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Set the scale of the object. + * Mutate the object. + * + * @param obj object to be set + * @param x new x scale + * @param y new y scale + * @returns game object itself + */ +export function set_scale( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setScale(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Set the rotation of the object. + * Mutate the object. + * + * @param obj object to be set + * @param rad the rotation, in radians + * @returns game object itself + */ +export function set_rotation(obj: GameObject, rad: number): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setRotation(rad); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Sets the horizontal and flipped state of the object. + * Mutate the object. + * + * @param obj game object itself + * @param x to flip in the horizontal state + * @param y to flip in the vertical state + * @returns game object itself + */ +export function set_flip( + obj: GameObject, + x: boolean, + y: boolean, +): GameObject | undefined { + const GameElementType = [ObjectTypes.ImageType, ObjectTypes.TextType]; + if (is_any_type(obj, GameElementType)) { + (get_obj(obj) as RawGameElement).setFlip(x, y); + return obj; + } + throw_error(`${obj} is not of type ${GameElementType}`); +} + +/** + * Creates a tween to the object and plays it. + * Mutate the object. + * + * @param obj object to be added to + * @param config tween config + * @returns game object itself + */ +export async function add_tween( + obj: GameObject, + config: ObjectConfig = {}, +): Promise { + if (is_any_type(obj, ObjTypes)) { + scene().tweens.add({ + targets: get_game_obj(obj), + ...config, + }); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +// LISTENER + +/** + * Attach a listener to the object. The callback will be executed + * when the event is emitted. + * Mutate the object. + * + * For all available events, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html + * + * @param obj object to be added to + * @param event the event name + * @param callback listener function, executed on event + * @returns listener game object + */ +export function add_listener( + obj: GameObject, + event: string, + callback: Function, +): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + const listener = get_game_obj(obj) + .addListener(event, callback); + return set_type(listener, ListenerTypes.InputPlugin); + } + throw_error(`${obj} is not of type ${ObjTypes}`); +} + +/** + * Attach a listener to the object. The callback will be executed + * when the event is emitted. + * Mutate the object. + * + * For all available events, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html + * + * For list of keycodes, see: + * https://github.com/photonstorm/phaser/blob/v3.22.0/src/input/keyboard/keys/KeyCodes.js + * + * @param key keyboard key to trigger listener + * @param event the event name + * @param callback listener function, executed on event + * @returns listener game object + */ +export function add_keyboard_listener( + key: string | number, + event: string, + callback: Function, +): GameObject { + const keyObj = scene().input.keyboard.addKey(key); + const keyboardListener = keyObj.addListener(event, callback); + return set_type(keyboardListener, ListenerTypes.KeyboardKeyType); +} + +/** + * Deactivate and remove listener. + * + * @param listener + * @returns if successful + */ +export function remove_listener(listener: GameObject): boolean { + if (is_any_type(listener, ListnerTypes)) { + get_input_obj(listener) + .removeAllListeners(); + return true; + } + return false; +} + +const gameFunctions = [ + add, + add_listener, + add_keyboard_listener, + add_to_container, + add_tween, + create_anim, + create_anim_config, + create_anim_frame_config, + create_anim_spritesheet_frame_configs, + create_award, + create_config, + create_container, + create_ellipse, + create_image, + create_interactive_config, + create_rect, + create_text, + create_text_config, + create_tween_config, + create_sound_config, + create_spritesheet_config, + destroy_obj, + get_screen_width, + get_screen_height, + get_screen_display_width, + get_screen_display_height, + load_image, + load_sound, + load_spritesheet, + play_anim_on_image, + play_sound, + prepend_remote_url, + remove_listener, + set_alpha, + set_display_size, + set_flip, + set_interactive, + set_origin, + set_position, + set_rotation, + set_scale, +]; + +// Inject minArgsNeeded to allow module varargs +// Remove if module varargs is fixed on js-slang side +gameFunctions.forEach((fn) => { + const dummy = fn as any; + dummy.minArgsNeeded = fn.length; +}); diff --git a/src/bundles/game/index.ts b/src/bundles/game/index.ts index 1faf72013..a4ab431d2 100644 --- a/src/bundles/game/index.ts +++ b/src/bundles/game/index.ts @@ -1,59 +1,59 @@ -/** - * Game library that translates Phaser 3 API into Source. - * - * More in-depth explanation of the Phaser 3 API can be found at - * Phaser 3 documentation itself. - * - * For Phaser 3 API Documentation, check: - * https://photonstorm.github.io/phaser3-docs/ - * - * @module game - * @author Anthony Halim - * @author Chi Xu - * @author Chong Sia Tiffany - * @author Gokul Rajiv - */ - -export { - add, - add_listener, - add_keyboard_listener, - add_to_container, - add_tween, - create_anim, - create_anim_config, - create_anim_frame_config, - create_anim_spritesheet_frame_configs, - create_award, - create_config, - create_container, - create_ellipse, - create_image, - create_interactive_config, - create_rect, - create_text, - create_text_config, - create_tween_config, - create_sound_config, - create_spritesheet_config, - destroy_obj, - get_screen_width, - get_screen_height, - get_screen_display_width, - get_screen_display_height, - load_image, - load_sound, - load_spritesheet, - play_anim_on_image, - play_sound, - prepend_remote_url, - remove_listener, - set_alpha, - set_display_size, - set_flip, - set_interactive, - set_origin, - set_position, - set_rotation, - set_scale, -} from './functions'; +/** + * Game library that translates Phaser 3 API into Source. + * + * More in-depth explanation of the Phaser 3 API can be found at + * Phaser 3 documentation itself. + * + * For Phaser 3 API Documentation, check: + * https://photonstorm.github.io/phaser3-docs/ + * + * @module game + * @author Anthony Halim + * @author Chi Xu + * @author Chong Sia Tiffany + * @author Gokul Rajiv + */ + +export { + add, + add_listener, + add_keyboard_listener, + add_to_container, + add_tween, + create_anim, + create_anim_config, + create_anim_frame_config, + create_anim_spritesheet_frame_configs, + create_award, + create_config, + create_container, + create_ellipse, + create_image, + create_interactive_config, + create_rect, + create_text, + create_text_config, + create_tween_config, + create_sound_config, + create_spritesheet_config, + destroy_obj, + get_screen_width, + get_screen_height, + get_screen_display_width, + get_screen_display_height, + load_image, + load_sound, + load_spritesheet, + play_anim_on_image, + play_sound, + prepend_remote_url, + remove_listener, + set_alpha, + set_display_size, + set_flip, + set_interactive, + set_origin, + set_position, + set_rotation, + set_scale, +} from './functions'; diff --git a/src/bundles/game/types.ts b/src/bundles/game/types.ts index 6f821e994..68902f7d0 100644 --- a/src/bundles/game/types.ts +++ b/src/bundles/game/types.ts @@ -1,62 +1,62 @@ -import * as Phaser from 'phaser'; - -export type ObjectConfig = { [attr: string]: any }; - -export type RawGameElement = - | Phaser.GameObjects.Sprite - | Phaser.GameObjects.Text; - -export type RawGameShape = - | Phaser.GameObjects.Rectangle - | Phaser.GameObjects.Ellipse; - -export type RawGameObject = RawGameElement | RawGameShape; - -export type RawContainer = Phaser.GameObjects.Container; - -export type RawInputObject = - | Phaser.Input.InputPlugin - | Phaser.Input.Keyboard.Key; - -export type GameObject = { - type: string; - object: RawGameObject | RawInputObject | RawContainer | undefined; -}; - -export type GameParams = { - scene: Phaser.Scene | undefined; - preloadImageMap: Map; - preloadSoundMap: Map; - preloadSpritesheetMap: Map; - lifecycleFuncs: { - preload: () => void; - create: () => void; - update: () => void; - }; - renderPreview: boolean; - remotePath: (path: string) => string; - screenSize: { x: number; y: number }; - createAward: (x: number, y: number, key: string) => Phaser.GameObjects.Sprite; -}; - -export const sourceAcademyAssets = 'https://source-academy-assets.s3-ap-southeast-1.amazonaws.com'; - -// Scene needs to be set when available! -export const defaultGameParams: GameParams = { - scene: undefined, - preloadImageMap: new Map(), - preloadSoundMap: new Map(), - preloadSpritesheetMap: new Map(), - lifecycleFuncs: { - preload() {}, - create() {}, - update() {}, - }, - renderPreview: false, - remotePath: (path: string) => sourceAcademyAssets + (path[0] === '/' ? '' : '/') + path, - screenSize: { - x: 1920, - y: 1080, - }, - createAward: (x: number, y: number, key: string) => new Phaser.GameObjects.Sprite(defaultGameParams.scene!, x, y, key), -}; +import * as Phaser from 'phaser'; + +export type ObjectConfig = { [attr: string]: any }; + +export type RawGameElement = + | Phaser.GameObjects.Sprite + | Phaser.GameObjects.Text; + +export type RawGameShape = + | Phaser.GameObjects.Rectangle + | Phaser.GameObjects.Ellipse; + +export type RawGameObject = RawGameElement | RawGameShape; + +export type RawContainer = Phaser.GameObjects.Container; + +export type RawInputObject = + | Phaser.Input.InputPlugin + | Phaser.Input.Keyboard.Key; + +export type GameObject = { + type: string; + object: RawGameObject | RawInputObject | RawContainer | undefined; +}; + +export type GameParams = { + scene: Phaser.Scene | undefined; + preloadImageMap: Map; + preloadSoundMap: Map; + preloadSpritesheetMap: Map; + lifecycleFuncs: { + preload: () => void; + create: () => void; + update: () => void; + }; + renderPreview: boolean; + remotePath: (path: string) => string; + screenSize: { x: number; y: number }; + createAward: (x: number, y: number, key: string) => Phaser.GameObjects.Sprite; +}; + +export const sourceAcademyAssets = 'https://source-academy-assets.s3-ap-southeast-1.amazonaws.com'; + +// Scene needs to be set when available! +export const defaultGameParams: GameParams = { + scene: undefined, + preloadImageMap: new Map(), + preloadSoundMap: new Map(), + preloadSpritesheetMap: new Map(), + lifecycleFuncs: { + preload() {}, + create() {}, + update() {}, + }, + renderPreview: false, + remotePath: (path: string) => sourceAcademyAssets + (path[0] === '/' ? '' : '/') + path, + screenSize: { + x: 1920, + y: 1080, + }, + createAward: (x: number, y: number, key: string) => new Phaser.GameObjects.Sprite(defaultGameParams.scene!, x, y, key), +}; diff --git a/src/bundles/repl/config.ts b/src/bundles/repl/config.ts index e873007b1..9512fcdd8 100644 --- a/src/bundles/repl/config.ts +++ b/src/bundles/repl/config.ts @@ -1,10 +1,10 @@ -export const COLOR_REPL_DISPLAY_DEFAULT = 'cyan'; -export const COLOR_RUN_CODE_RESULT = 'white'; -export const COLOR_ERROR_MESSAGE = 'red'; -export const FONT_MESSAGE = { - fontFamily: 'Inconsolata, Consolas, monospace', - fontSize: '16px', - fontWeight: 'normal', -}; -export const DEFAULT_EDITOR_HEIGHT = 375; -export const MINIMUM_EDITOR_HEIGHT = 40; +export const COLOR_REPL_DISPLAY_DEFAULT = 'cyan'; +export const COLOR_RUN_CODE_RESULT = 'white'; +export const COLOR_ERROR_MESSAGE = 'red'; +export const FONT_MESSAGE = { + fontFamily: 'Inconsolata, Consolas, monospace', + fontSize: '16px', + fontWeight: 'normal', +}; +export const DEFAULT_EDITOR_HEIGHT = 375; +export const MINIMUM_EDITOR_HEIGHT = 40; diff --git a/src/bundles/repl/functions.ts b/src/bundles/repl/functions.ts index 38680d0d7..914ceea70 100644 --- a/src/bundles/repl/functions.ts +++ b/src/bundles/repl/functions.ts @@ -1,119 +1,119 @@ -/** - * Functions for Programmable REPL - * @module repl - * @author Wang Zihan - */ - -import context from 'js-slang/context'; -import { ProgrammableRepl } from './programmable_repl'; -import { COLOR_REPL_DISPLAY_DEFAULT } from './config'; - -const INSTANCE = new ProgrammableRepl(); -context.moduleContexts.repl.state = INSTANCE; -/** - * Setup the programmable REPL with given evaulator's entrance function - * - * The function should take one parameter as the code from the module's editor, for example: - * ```js - * function parse_and_evaluate(code) { - * // ... - * } - * ``` - * @param {evalFunc} evalFunc - evaulator entrance function - * - * @category Main - */ -export function set_evaluator(evalFunc: Function) { - if (!(evalFunc instanceof Function)) { - const typeName = typeof (evalFunc); - throw new Error(`Wrong parameter type "${typeName}' in function "set_evaluator". It supposed to be a function and it's the entrance function of your metacircular evaulator.`); - } - INSTANCE.evalFunction = evalFunc; - return { - toReplString: () => '', - }; -} - - -/** - * Display message in Programmable Repl Tab - * If you give a pair as the parameter, it will use the given pair to generate rich text and use rich text display mode to display the string in Programmable Repl Tab with undefined return value (see module description for more information). - * If you give other things as the parameter, it will simply display the toString value of the parameter in Programmable Repl Tab and returns the displayed string itself. - * - * **Rich Text Display** - * - First you need to `import { repl_display } from "repl";` - * - Format: pair(pair("string",style),style)... - * - Examples: - * - * ```js - * // A large italic underlined "Hello World" - * repl_display(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic")); - * - * // A large italic underlined "Hello World" in blue - * repl_display(pair(pair(pair(pair(pair("Hello World", "underline"),"italic"), "bold"), "gigantic"), "clrt#0000ff")); - * - * // A large italic underlined "Hello World" with orange foreground and purple background - * repl_display(pair(pair(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic"), "clrb#A000A0"),"clrt#ff9700")); - * ``` - * - * - Coloring: - * - `clrt` stands for text color, `clrb` stands for background color. The color string are in hexadecimal begin with "#" and followed by 6 hexadecimal digits. - * - Example: `pair("123","clrt#ff0000")` will produce a red "123"; `pair("456","clrb#00ff00")` will produce a green "456". - * - Besides coloring, the following styles are also supported: - * - `bold`: Make the text bold. - * - `italic`: Make the text italic. - * - `small`: Make the text in small size. - * - `medium`: Make the text in medium size. - * - `large`: Make the text in large size. - * - `gigantic`: Make the text in very large size. - * - `underline`: Underline the text. - * - Note that if you apply the conflicting attributes together, only one conflicted style will take effect and other conflicting styles will be discarded, like "pair(pair(pair("123", small), medium), large) " (Set conflicting font size for the same text) - * - Also note that for safety matters, certain words and characters are not allowed to appear under rich text display mode. - * - * @param {content} the content you want to display - * @category Main - */ -export function repl_display(content: any) : any { - if (INSTANCE.richDisplayInternal(content) === 'not_rich_text_pair') { - INSTANCE.pushOutputString(content.toString(), COLOR_REPL_DISPLAY_DEFAULT, 'plaintext');// students may set the value of the parameter "str" to types other than a string (for example "repl_display(1)" ). So here I need to first convert the parameter "str" into a string before preceding. - return content; - } - return undefined; -} - - -/** - * Set Programmable Repl editor background image with a customized image URL - * @param {img_url} the url to the new background image - * @param {background_color_alpha} the alpha (transparency) of the original background color that covers on your background image [0 ~ 1]. Recommended value is 0.5 . - * - * @category Main - */ -export function set_background_image(img_url: string, background_color_alpha: number) : void { - INSTANCE.customizedEditorProps.backgroundImageUrl = img_url; - INSTANCE.customizedEditorProps.backgroundColorAlpha = background_color_alpha; -} - - -/** - * Set Programmable Repl editor font size - * @param {font_size_px} font size (in pixel) - * - * @category Main - */ -export function set_font_size(font_size_px: number) { - INSTANCE.customizedEditorProps.fontSize = parseInt(font_size_px.toString());// The TypeScript type checker will throw an error as "parseInt" in TypeScript only accepts one string as parameter. -} - -/** - * When use this function as the entrance function in the parameter of "set_evaluator", the Programmable Repl will directly use the default js-slang interpreter to run your program in Programmable Repl editor. Do not directly call this function in your own code. - * @param {program} Do not directly set this parameter in your code. - * @param {safeKey} A parameter that is designed to prevent student from directly calling this function in Source language. - * - * @category Main - */ -export function default_js_slang(_program: string) : any { - throw new Error('Invaild Call: Function "default_js_slang" can not be directly called by user\'s code in editor. You should use it as the parameter of the function "set_evaluator"'); - // When the function is normally called by set_evaluator function, safeKey is set to "document.body", which has a type "Element". - // Students can not create objects and use HTML Elements in Source due to limitations and rules in Source, so they can't set the safeKey to a HTML Element, thus they can't use this function in Source. -} +/** + * Functions for Programmable REPL + * @module repl + * @author Wang Zihan + */ + +import context from 'js-slang/context'; +import { ProgrammableRepl } from './programmable_repl'; +import { COLOR_REPL_DISPLAY_DEFAULT } from './config'; + +const INSTANCE = new ProgrammableRepl(); +context.moduleContexts.repl.state = INSTANCE; +/** + * Setup the programmable REPL with given evaulator's entrance function + * + * The function should take one parameter as the code from the module's editor, for example: + * ```js + * function parse_and_evaluate(code) { + * // ... + * } + * ``` + * @param {evalFunc} evalFunc - evaulator entrance function + * + * @category Main + */ +export function set_evaluator(evalFunc: Function) { + if (!(evalFunc instanceof Function)) { + const typeName = typeof (evalFunc); + throw new Error(`Wrong parameter type "${typeName}' in function "set_evaluator". It supposed to be a function and it's the entrance function of your metacircular evaulator.`); + } + INSTANCE.evalFunction = evalFunc; + return { + toReplString: () => '', + }; +} + + +/** + * Display message in Programmable Repl Tab + * If you give a pair as the parameter, it will use the given pair to generate rich text and use rich text display mode to display the string in Programmable Repl Tab with undefined return value (see module description for more information). + * If you give other things as the parameter, it will simply display the toString value of the parameter in Programmable Repl Tab and returns the displayed string itself. + * + * **Rich Text Display** + * - First you need to `import { repl_display } from "repl";` + * - Format: pair(pair("string",style),style)... + * - Examples: + * + * ```js + * // A large italic underlined "Hello World" + * repl_display(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic")); + * + * // A large italic underlined "Hello World" in blue + * repl_display(pair(pair(pair(pair(pair("Hello World", "underline"),"italic"), "bold"), "gigantic"), "clrt#0000ff")); + * + * // A large italic underlined "Hello World" with orange foreground and purple background + * repl_display(pair(pair(pair(pair(pair(pair("Hello World", "underline"), "italic"), "bold"), "gigantic"), "clrb#A000A0"),"clrt#ff9700")); + * ``` + * + * - Coloring: + * - `clrt` stands for text color, `clrb` stands for background color. The color string are in hexadecimal begin with "#" and followed by 6 hexadecimal digits. + * - Example: `pair("123","clrt#ff0000")` will produce a red "123"; `pair("456","clrb#00ff00")` will produce a green "456". + * - Besides coloring, the following styles are also supported: + * - `bold`: Make the text bold. + * - `italic`: Make the text italic. + * - `small`: Make the text in small size. + * - `medium`: Make the text in medium size. + * - `large`: Make the text in large size. + * - `gigantic`: Make the text in very large size. + * - `underline`: Underline the text. + * - Note that if you apply the conflicting attributes together, only one conflicted style will take effect and other conflicting styles will be discarded, like "pair(pair(pair("123", small), medium), large) " (Set conflicting font size for the same text) + * - Also note that for safety matters, certain words and characters are not allowed to appear under rich text display mode. + * + * @param {content} the content you want to display + * @category Main + */ +export function repl_display(content: any) : any { + if (INSTANCE.richDisplayInternal(content) === 'not_rich_text_pair') { + INSTANCE.pushOutputString(content.toString(), COLOR_REPL_DISPLAY_DEFAULT, 'plaintext');// students may set the value of the parameter "str" to types other than a string (for example "repl_display(1)" ). So here I need to first convert the parameter "str" into a string before preceding. + return content; + } + return undefined; +} + + +/** + * Set Programmable Repl editor background image with a customized image URL + * @param {img_url} the url to the new background image + * @param {background_color_alpha} the alpha (transparency) of the original background color that covers on your background image [0 ~ 1]. Recommended value is 0.5 . + * + * @category Main + */ +export function set_background_image(img_url: string, background_color_alpha: number) : void { + INSTANCE.customizedEditorProps.backgroundImageUrl = img_url; + INSTANCE.customizedEditorProps.backgroundColorAlpha = background_color_alpha; +} + + +/** + * Set Programmable Repl editor font size + * @param {font_size_px} font size (in pixel) + * + * @category Main + */ +export function set_font_size(font_size_px: number) { + INSTANCE.customizedEditorProps.fontSize = parseInt(font_size_px.toString());// The TypeScript type checker will throw an error as "parseInt" in TypeScript only accepts one string as parameter. +} + +/** + * When use this function as the entrance function in the parameter of "set_evaluator", the Programmable Repl will directly use the default js-slang interpreter to run your program in Programmable Repl editor. Do not directly call this function in your own code. + * @param {program} Do not directly set this parameter in your code. + * @param {safeKey} A parameter that is designed to prevent student from directly calling this function in Source language. + * + * @category Main + */ +export function default_js_slang(_program: string) : any { + throw new Error('Invaild Call: Function "default_js_slang" can not be directly called by user\'s code in editor. You should use it as the parameter of the function "set_evaluator"'); + // When the function is normally called by set_evaluator function, safeKey is set to "document.body", which has a type "Element". + // Students can not create objects and use HTML Elements in Source due to limitations and rules in Source, so they can't set the safeKey to a HTML Element, thus they can't use this function in Source. +} diff --git a/src/bundles/repl/index.ts b/src/bundles/repl/index.ts index ec02024a2..456cccf5f 100644 --- a/src/bundles/repl/index.ts +++ b/src/bundles/repl/index.ts @@ -1,46 +1,46 @@ -/** - * ## Example of usage: - * ### Use with metacircular evaluator: - * ```js - * import { set_evaluator, repl_display } from "repl"; - * - * const primitive_functions = list( - * // (omitted other primitive functions) - * list("display", repl_display), // Here change this from "display" to "repl_display" to let the display result goes to the repl tab. - * // (omitted other primitive functions) - * } - * - * function parse_and_evaluate(code){ - * // (your metacircular evaluator entry function) - * } - * - * set_evaluator(parse_and_evaluate); // This can invoke the repl with your metacircular evaluator's evaluation entry - * ``` - * - * ### Use with Source Academy's builtin js-slang - * ```js - * import { set_evaluator, default_js_slang, repl_display } from "repl"; // Here you also need to import "repl_display" along with "set_evaluator" and "default_js_slang". - * - * set_evaluator(default_js_slang); // This can invoke the repl with Source Academy's builtin js-slang evaluation entry - * ``` - * (Note that you can't directly call "default_js_slang" in your own code. It should only be used as the parameter of "set_evaluator") - * - * - * ### Customize Editor Appearance - * ```js - * import { set_background_image, set_font_size } from "repl"; - * set_background_image("https://www.some_image_website.xyz/your_favorite_image.png"); // Set the background image of the editor in repl tab - * set_font_size(20.5); // Set the font size of the editor in repl tab - * ``` - * - * @module repl - * @author Wang Zihan -*/ - -export { - set_evaluator, - repl_display, - set_background_image, - set_font_size, - default_js_slang, -} from './functions'; +/** + * ## Example of usage: + * ### Use with metacircular evaluator: + * ```js + * import { set_evaluator, repl_display } from "repl"; + * + * const primitive_functions = list( + * // (omitted other primitive functions) + * list("display", repl_display), // Here change this from "display" to "repl_display" to let the display result goes to the repl tab. + * // (omitted other primitive functions) + * } + * + * function parse_and_evaluate(code){ + * // (your metacircular evaluator entry function) + * } + * + * set_evaluator(parse_and_evaluate); // This can invoke the repl with your metacircular evaluator's evaluation entry + * ``` + * + * ### Use with Source Academy's builtin js-slang + * ```js + * import { set_evaluator, default_js_slang, repl_display } from "repl"; // Here you also need to import "repl_display" along with "set_evaluator" and "default_js_slang". + * + * set_evaluator(default_js_slang); // This can invoke the repl with Source Academy's builtin js-slang evaluation entry + * ``` + * (Note that you can't directly call "default_js_slang" in your own code. It should only be used as the parameter of "set_evaluator") + * + * + * ### Customize Editor Appearance + * ```js + * import { set_background_image, set_font_size } from "repl"; + * set_background_image("https://www.some_image_website.xyz/your_favorite_image.png"); // Set the background image of the editor in repl tab + * set_font_size(20.5); // Set the font size of the editor in repl tab + * ``` + * + * @module repl + * @author Wang Zihan +*/ + +export { + set_evaluator, + repl_display, + set_background_image, + set_font_size, + default_js_slang, +} from './functions'; diff --git a/src/bundles/repl/programmable_repl.ts b/src/bundles/repl/programmable_repl.ts index 707b2ecc0..77fa17972 100644 --- a/src/bundles/repl/programmable_repl.ts +++ b/src/bundles/repl/programmable_repl.ts @@ -1,262 +1,262 @@ -/** - * Source Academy Programmable REPL module - * @module repl - * @author Wang Zihan - */ - - -import context from 'js-slang/context'; -import { default_js_slang } from './functions'; -import { runFilesInContext, type IOptions } from 'js-slang'; -import { COLOR_RUN_CODE_RESULT, COLOR_ERROR_MESSAGE, DEFAULT_EDITOR_HEIGHT } from './config'; - -export class ProgrammableRepl { - public evalFunction: Function; - public userCodeInEditor: string; - public outputStrings: any[]; - private _editorInstance; - private _tabReactComponent: any; - // I store editorHeight value separately in here although it is already stored in the module's Tab React component state because I need to keep the editor height - // when the Tab component is re-mounted due to the user drags the area between the module's Tab and Source Academy's original REPL to resize the module's Tab height. - public editorHeight : number; - - public customizedEditorProps = { - backgroundImageUrl: 'no-background-image', - backgroundColorAlpha: 1, - fontSize: 17, - }; - - constructor() { - this.evalFunction = (_placeholder) => this.easterEggFunction(); - this.userCodeInEditor = this.getSavedEditorContent(); - this.outputStrings = []; - this._editorInstance = null;// To be set when calling "SetEditorInstance" in the ProgrammableRepl Tab React Component render function. - this.editorHeight = DEFAULT_EDITOR_HEIGHT; - developmentLog(this); - } - - InvokeREPL_Internal(evalFunc: Function) { - this.evalFunction = evalFunc; - } - - runCode() { - this.outputStrings = []; - let retVal: any; - try { - if (Object.is(this.evalFunction, default_js_slang)) { - retVal = this.runInJsSlang(this.userCodeInEditor); - } else { - retVal = this.evalFunction(this.userCodeInEditor); - } - } catch (exception: any) { - developmentLog(exception); - // If the exception has a start line of -1 and an undefined error property, then this exception is most likely to be "incorrect number of arguments" caused by incorrect number of parameters in the evaluator entry function provided by students with set_evaluator. - if (exception.location.start.line === -1 && exception.error === undefined) { - this.pushOutputString('Error: Unable to use your evaluator to run the code. Does your evaluator entry function contain and only contain exactly one parameter?', COLOR_ERROR_MESSAGE); - } else { - this.pushOutputString(`Line ${exception.location.start.line.toString()}: ${exception.error?.message}`, COLOR_ERROR_MESSAGE); - } - this.reRenderTab(); - return; - } - if (typeof (retVal) === 'string') { - retVal = `"${retVal}"`; - } - // Here must use plain text output mode because retVal contains strings from the users. - this.pushOutputString(retVal, COLOR_RUN_CODE_RESULT); - this.reRenderTab(); - developmentLog('RunCode finished'); - } - - updateUserCode(code) { - this.userCodeInEditor = code; - } - - // Rich text output method allow output strings to have html tags and css styles. - pushOutputString(content : string, textColor : string, outputMethod : string = 'plaintext') { - let tmp = { - content: content === undefined ? 'undefined' : content === null ? 'null' : content, - color: textColor, - outputMethod, - }; - this.outputStrings.push(tmp); - } - - setEditorInstance(instance: any) { - if (instance === undefined) return; // It seems that when calling this function in gui->render->ref, the React internal calls this function for multiple times (at least two times) , and in at least one call the parameter 'instance' is set to 'undefined'. If I don't add this if statement, the program will throw a runtime error when rendering tab. - this._editorInstance = instance; - this._editorInstance.on('guttermousedown', (e) => { - const breakpointLine = e.getDocumentPosition().row; - developmentLog(breakpointLine); - }); - - this._editorInstance.setOptions({ fontSize: `${this.customizedEditorProps.fontSize.toString()}pt` }); - } - - richDisplayInternal(pair_rich_text) { - developmentLog(pair_rich_text); - const head = (pair) => pair[0]; - const tail = (pair) => pair[1]; - const is_pair = (obj) => obj instanceof Array && obj.length === 2; - if (!is_pair(pair_rich_text)) return 'not_rich_text_pair'; - function checkColorStringValidity(htmlColor:string) { - if (htmlColor.length !== 7) return false; - if (htmlColor[0] !== '#') return false; - for (let i = 1; i < 7; i++) { - const char = htmlColor[i]; - developmentLog(` ${char}`); - if (!((char >= '0' && char <= '9') || (char >= 'A' && char <= 'F') || (char >= 'a' && char <= 'f'))) { - return false; - } - } - return true; - } - function recursiveHelper(thisInstance, param): string { - if (typeof (param) === 'string') { - // There MUST be a safe check on users' strings, because users may insert something that can be interpreted as executable JavaScript code when outputing rich text. - const safeCheckResult = thisInstance.userStringSafeCheck(param); - if (safeCheckResult !== 'safe') { - throw new Error(`For safety matters, the character/word ${safeCheckResult} is not allowed in rich text output. Please remove it or use plain text output mode and try again.`); - } - developmentLog(head(param)); - return `">${param}`; - } - if (!is_pair(param)) { - throw new Error(`Unexpected data type ${typeof (param)} when processing rich text. It should be a pair.`); - } else { - const pairStyleToCssStyle : { [pairStyle : string] : string } = { - bold: 'font-weight:bold;', - italic: 'font-style:italic;', - small: 'font-size: 14px;', - medium: 'font-size: 20px;', - large: 'font-size: 25px;', - gigantic: 'font-size: 50px;', - underline: 'text-decoration: underline;', - }; - if (typeof (tail(param)) !== 'string') { - throw new Error(`The tail in style pair should always be a string, but got ${typeof (tail(param))}.`); - } - let style = ''; - if (tail(param) - .substring(0, 3) === 'clr') { - let prefix = ''; - if (tail(param)[3] === 't') prefix = 'color:'; - else if (tail(param)[3] === 'b') prefix = 'background-color:'; - else throw new Error('Error when decoding rich text color data'); - const colorHex = tail(param) - .substring(4); - if (!checkColorStringValidity(colorHex)) { - throw new Error(`Invalid html color string ${colorHex}. It should start with # and followed by 6 characters representing a hex number.`); - } - style = `${prefix + colorHex};`; - } else { - style = pairStyleToCssStyle[tail(param)]; - if (style === undefined) { - throw new Error(`Found undefined style ${tail(param)} during processing rich text.`); - } - } - return style + recursiveHelper(thisInstance, head(param)); - } - } - this.pushOutputString(`', 'script', 'javascript', 'eval', 'document', 'window', 'console', 'location']; - for (let word of forbiddenWords) { - if (tmp.indexOf(word) !== -1) { - return word; - } - } - return 'safe'; - } - - /* - Directly invoking Source Academy's builtin js-slang runner. - Needs hard-coded support from js-slang part for the "sourceRunner" function and "backupContext" property in the content object for this to work. - */ - runInJsSlang(code: string): string { - developmentLog('js-slang context:'); - // console.log(context); - const options : Partial = { - originalMaxExecTime: 1000, - scheduler: 'preemptive', - stepLimit: 1000, - throwInfiniteLoops: true, - useSubst: false, - }; - context.prelude = 'const display=(x)=>repl_display(x);'; - context.errors = []; // Here if I don't manually clear the "errors" array in context, the remaining errors from the last evaluation will stop the function "preprocessFileImports" in preprocessor.ts of js-slang thus stop the whole evaluation. - const sourceFile : Record = { - '/ReplModuleUserCode.js': code, - }; - - runFilesInContext(sourceFile, '/ReplModuleUserCode.js', context, options) - .then((evalResult) => { - if (evalResult.status === 'suspended' || evalResult.status === 'suspended-ec-eval') { - throw new Error('This should not happen'); - } - if (evalResult.status !== 'error') { - this.pushOutputString('js-slang program finished with value:', COLOR_RUN_CODE_RESULT); - // Here must use plain text output mode because evalResult.value contains strings from the users. - this.pushOutputString(evalResult.value === undefined ? 'undefined' : evalResult.value.toString(), COLOR_RUN_CODE_RESULT); - } else { - const errors = context.errors; - console.log(errors); - const errorCount = errors.length; - for (let i = 0; i < errorCount; i++) { - const error = errors[i]; - if (error.explain() - .indexOf('Name repl_display not declared.') !== -1) { - this.pushOutputString('[Error] It seems that you haven\'t import the function "repl_display" correctly when calling "set_evaluator" in Source Academy\'s main editor.', COLOR_ERROR_MESSAGE); - } else this.pushOutputString(`Line ${error.location.start.line}: ${error.type} Error: ${error.explain()} (${error.elaborate()})`, COLOR_ERROR_MESSAGE); - } - } - this.reRenderTab(); - }); - - return 'Async run in js-slang'; - } - - setTabReactComponentInstance(tab : any) { - this._tabReactComponent = tab; - } - - private reRenderTab() { - this._tabReactComponent.setState({});// Forces the tab React Component to re-render using setState - } - - saveEditorContent() { - localStorage.setItem('programmable_repl_saved_editor_code', this.userCodeInEditor.toString()); - this.pushOutputString('Saved', 'lightgreen'); - this.pushOutputString('The saved code is stored locally in your browser. You may lose the saved code if you clear browser data or use another device.', 'gray', 'richtext'); - this.reRenderTab(); - } - - private getSavedEditorContent() { - let savedContent = localStorage.getItem('programmable_repl_saved_editor_code'); - if (savedContent === null) return ''; - return savedContent; - } - - // Small Easter Egg that doesn't affect module functionality and normal user experience :) - // Please don't modify these text! Thanks! :) - private easterEggFunction() { - this.pushOutputString('[Author (Wang Zihan)] ❤I love Keqing and Ganyu.❤', 'pink', 'richtext'); - this.pushOutputString('Showing my love to my favorite girls through a SA module, is that the so-called "romance of a programmer"?', 'gray', 'richtext'); - this.pushOutputString('❤❤❤❤❤', 'pink'); - this.pushOutputString('
', 'white', 'richtext'); - this.pushOutputString('If you see this, please check whether you have called set_evaluator function with the correct parameter before using the Programmable Repl Tab.', 'yellow', 'richtext'); - return 'Easter Egg!'; - } -} - -// Comment all the codes inside this function before merging the code to github as production version. -// Because console.log() can expose the sandboxed VM location to students thus may cause security concerns. -function developmentLog(_content) { - // console.log(`[Programmable Repl Log] ${_content}`); -} +/** + * Source Academy Programmable REPL module + * @module repl + * @author Wang Zihan + */ + + +import context from 'js-slang/context'; +import { default_js_slang } from './functions'; +import { runFilesInContext, type IOptions } from 'js-slang'; +import { COLOR_RUN_CODE_RESULT, COLOR_ERROR_MESSAGE, DEFAULT_EDITOR_HEIGHT } from './config'; + +export class ProgrammableRepl { + public evalFunction: Function; + public userCodeInEditor: string; + public outputStrings: any[]; + private _editorInstance; + private _tabReactComponent: any; + // I store editorHeight value separately in here although it is already stored in the module's Tab React component state because I need to keep the editor height + // when the Tab component is re-mounted due to the user drags the area between the module's Tab and Source Academy's original REPL to resize the module's Tab height. + public editorHeight : number; + + public customizedEditorProps = { + backgroundImageUrl: 'no-background-image', + backgroundColorAlpha: 1, + fontSize: 17, + }; + + constructor() { + this.evalFunction = (_placeholder) => this.easterEggFunction(); + this.userCodeInEditor = this.getSavedEditorContent(); + this.outputStrings = []; + this._editorInstance = null;// To be set when calling "SetEditorInstance" in the ProgrammableRepl Tab React Component render function. + this.editorHeight = DEFAULT_EDITOR_HEIGHT; + developmentLog(this); + } + + InvokeREPL_Internal(evalFunc: Function) { + this.evalFunction = evalFunc; + } + + runCode() { + this.outputStrings = []; + let retVal: any; + try { + if (Object.is(this.evalFunction, default_js_slang)) { + retVal = this.runInJsSlang(this.userCodeInEditor); + } else { + retVal = this.evalFunction(this.userCodeInEditor); + } + } catch (exception: any) { + developmentLog(exception); + // If the exception has a start line of -1 and an undefined error property, then this exception is most likely to be "incorrect number of arguments" caused by incorrect number of parameters in the evaluator entry function provided by students with set_evaluator. + if (exception.location.start.line === -1 && exception.error === undefined) { + this.pushOutputString('Error: Unable to use your evaluator to run the code. Does your evaluator entry function contain and only contain exactly one parameter?', COLOR_ERROR_MESSAGE); + } else { + this.pushOutputString(`Line ${exception.location.start.line.toString()}: ${exception.error?.message}`, COLOR_ERROR_MESSAGE); + } + this.reRenderTab(); + return; + } + if (typeof (retVal) === 'string') { + retVal = `"${retVal}"`; + } + // Here must use plain text output mode because retVal contains strings from the users. + this.pushOutputString(retVal, COLOR_RUN_CODE_RESULT); + this.reRenderTab(); + developmentLog('RunCode finished'); + } + + updateUserCode(code) { + this.userCodeInEditor = code; + } + + // Rich text output method allow output strings to have html tags and css styles. + pushOutputString(content : string, textColor : string, outputMethod : string = 'plaintext') { + let tmp = { + content: content === undefined ? 'undefined' : content === null ? 'null' : content, + color: textColor, + outputMethod, + }; + this.outputStrings.push(tmp); + } + + setEditorInstance(instance: any) { + if (instance === undefined) return; // It seems that when calling this function in gui->render->ref, the React internal calls this function for multiple times (at least two times) , and in at least one call the parameter 'instance' is set to 'undefined'. If I don't add this if statement, the program will throw a runtime error when rendering tab. + this._editorInstance = instance; + this._editorInstance.on('guttermousedown', (e) => { + const breakpointLine = e.getDocumentPosition().row; + developmentLog(breakpointLine); + }); + + this._editorInstance.setOptions({ fontSize: `${this.customizedEditorProps.fontSize.toString()}pt` }); + } + + richDisplayInternal(pair_rich_text) { + developmentLog(pair_rich_text); + const head = (pair) => pair[0]; + const tail = (pair) => pair[1]; + const is_pair = (obj) => obj instanceof Array && obj.length === 2; + if (!is_pair(pair_rich_text)) return 'not_rich_text_pair'; + function checkColorStringValidity(htmlColor:string) { + if (htmlColor.length !== 7) return false; + if (htmlColor[0] !== '#') return false; + for (let i = 1; i < 7; i++) { + const char = htmlColor[i]; + developmentLog(` ${char}`); + if (!((char >= '0' && char <= '9') || (char >= 'A' && char <= 'F') || (char >= 'a' && char <= 'f'))) { + return false; + } + } + return true; + } + function recursiveHelper(thisInstance, param): string { + if (typeof (param) === 'string') { + // There MUST be a safe check on users' strings, because users may insert something that can be interpreted as executable JavaScript code when outputing rich text. + const safeCheckResult = thisInstance.userStringSafeCheck(param); + if (safeCheckResult !== 'safe') { + throw new Error(`For safety matters, the character/word ${safeCheckResult} is not allowed in rich text output. Please remove it or use plain text output mode and try again.`); + } + developmentLog(head(param)); + return `">${param}
`; + } + if (!is_pair(param)) { + throw new Error(`Unexpected data type ${typeof (param)} when processing rich text. It should be a pair.`); + } else { + const pairStyleToCssStyle : { [pairStyle : string] : string } = { + bold: 'font-weight:bold;', + italic: 'font-style:italic;', + small: 'font-size: 14px;', + medium: 'font-size: 20px;', + large: 'font-size: 25px;', + gigantic: 'font-size: 50px;', + underline: 'text-decoration: underline;', + }; + if (typeof (tail(param)) !== 'string') { + throw new Error(`The tail in style pair should always be a string, but got ${typeof (tail(param))}.`); + } + let style = ''; + if (tail(param) + .substring(0, 3) === 'clr') { + let prefix = ''; + if (tail(param)[3] === 't') prefix = 'color:'; + else if (tail(param)[3] === 'b') prefix = 'background-color:'; + else throw new Error('Error when decoding rich text color data'); + const colorHex = tail(param) + .substring(4); + if (!checkColorStringValidity(colorHex)) { + throw new Error(`Invalid html color string ${colorHex}. It should start with # and followed by 6 characters representing a hex number.`); + } + style = `${prefix + colorHex};`; + } else { + style = pairStyleToCssStyle[tail(param)]; + if (style === undefined) { + throw new Error(`Found undefined style ${tail(param)} during processing rich text.`); + } + } + return style + recursiveHelper(thisInstance, head(param)); + } + } + this.pushOutputString(`', 'script', 'javascript', 'eval', 'document', 'window', 'console', 'location']; + for (let word of forbiddenWords) { + if (tmp.indexOf(word) !== -1) { + return word; + } + } + return 'safe'; + } + + /* + Directly invoking Source Academy's builtin js-slang runner. + Needs hard-coded support from js-slang part for the "sourceRunner" function and "backupContext" property in the content object for this to work. + */ + runInJsSlang(code: string): string { + developmentLog('js-slang context:'); + // console.log(context); + const options : Partial = { + originalMaxExecTime: 1000, + scheduler: 'preemptive', + stepLimit: 1000, + throwInfiniteLoops: true, + useSubst: false, + }; + context.prelude = 'const display=(x)=>repl_display(x);'; + context.errors = []; // Here if I don't manually clear the "errors" array in context, the remaining errors from the last evaluation will stop the function "preprocessFileImports" in preprocessor.ts of js-slang thus stop the whole evaluation. + const sourceFile : Record = { + '/ReplModuleUserCode.js': code, + }; + + runFilesInContext(sourceFile, '/ReplModuleUserCode.js', context, options) + .then((evalResult) => { + if (evalResult.status === 'suspended' || evalResult.status === 'suspended-ec-eval') { + throw new Error('This should not happen'); + } + if (evalResult.status !== 'error') { + this.pushOutputString('js-slang program finished with value:', COLOR_RUN_CODE_RESULT); + // Here must use plain text output mode because evalResult.value contains strings from the users. + this.pushOutputString(evalResult.value === undefined ? 'undefined' : evalResult.value.toString(), COLOR_RUN_CODE_RESULT); + } else { + const errors = context.errors; + console.log(errors); + const errorCount = errors.length; + for (let i = 0; i < errorCount; i++) { + const error = errors[i]; + if (error.explain() + .indexOf('Name repl_display not declared.') !== -1) { + this.pushOutputString('[Error] It seems that you haven\'t import the function "repl_display" correctly when calling "set_evaluator" in Source Academy\'s main editor.', COLOR_ERROR_MESSAGE); + } else this.pushOutputString(`Line ${error.location.start.line}: ${error.type} Error: ${error.explain()} (${error.elaborate()})`, COLOR_ERROR_MESSAGE); + } + } + this.reRenderTab(); + }); + + return 'Async run in js-slang'; + } + + setTabReactComponentInstance(tab : any) { + this._tabReactComponent = tab; + } + + private reRenderTab() { + this._tabReactComponent.setState({});// Forces the tab React Component to re-render using setState + } + + saveEditorContent() { + localStorage.setItem('programmable_repl_saved_editor_code', this.userCodeInEditor.toString()); + this.pushOutputString('Saved', 'lightgreen'); + this.pushOutputString('The saved code is stored locally in your browser. You may lose the saved code if you clear browser data or use another device.', 'gray', 'richtext'); + this.reRenderTab(); + } + + private getSavedEditorContent() { + let savedContent = localStorage.getItem('programmable_repl_saved_editor_code'); + if (savedContent === null) return ''; + return savedContent; + } + + // Small Easter Egg that doesn't affect module functionality and normal user experience :) + // Please don't modify these text! Thanks! :) + private easterEggFunction() { + this.pushOutputString('[Author (Wang Zihan)] ❤I love Keqing and Ganyu.❤', 'pink', 'richtext'); + this.pushOutputString('Showing my love to my favorite girls through a SA module, is that the so-called "romance of a programmer"?', 'gray', 'richtext'); + this.pushOutputString('❤❤❤❤❤', 'pink'); + this.pushOutputString('
', 'white', 'richtext'); + this.pushOutputString('If you see this, please check whether you have called set_evaluator function with the correct parameter before using the Programmable Repl Tab.', 'yellow', 'richtext'); + return 'Easter Egg!'; + } +} + +// Comment all the codes inside this function before merging the code to github as production version. +// Because console.log() can expose the sandboxed VM location to students thus may cause security concerns. +function developmentLog(_content) { + // console.log(`[Programmable Repl Log] ${_content}`); +} diff --git a/src/tabs/Repl/index.tsx b/src/tabs/Repl/index.tsx index ee5e09463..df66ec9d1 100644 --- a/src/tabs/Repl/index.tsx +++ b/src/tabs/Repl/index.tsx @@ -1,179 +1,179 @@ -/** - * Tab for Source Academy Programmable REPL module - * @module repl - * @author Wang Zihan - */ - -import React from 'react'; -import type { DebuggerContext } from '../../typings/type_helpers'; -import { Button } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import type { ProgrammableRepl } from '../../bundles/repl/programmable_repl'; -import { FONT_MESSAGE, MINIMUM_EDITOR_HEIGHT } from '../../bundles/repl/config'; -// If I use import for AceEditor it will cause runtime error and crash Source Academy when spawning tab in the new module building system. -// import AceEditor from 'react-ace'; -const AceEditor = require('react-ace').default; - -import 'ace-builds/src-noconflict/mode-javascript'; -import 'ace-builds/src-noconflict/theme-twilight'; -import 'ace-builds/src-noconflict/ext-language_tools'; - -type Props = { - programmableReplInstance: ProgrammableRepl; -}; - -type State = { - editorHeight: number, - isDraggingDragBar: boolean, -}; - -const BOX_PADDING_VALUE = 4; - -class ProgrammableReplGUI extends React.Component { - public replInstance : ProgrammableRepl; - private editorAreaRect; - private editorInstance; - constructor(data: Props) { - super(data); - this.replInstance = data.programmableReplInstance; - this.replInstance.setTabReactComponentInstance(this); - this.state = { - editorHeight: this.replInstance.editorHeight, - isDraggingDragBar: false, - }; - } - private dragBarOnMouseDown = (e) => { - e.preventDefault(); - this.setState({ isDraggingDragBar: true }); - }; - private onMouseMove = (e) => { - if (this.state.isDraggingDragBar) { - const height = Math.max(e.clientY - this.editorAreaRect.top - BOX_PADDING_VALUE * 2, MINIMUM_EDITOR_HEIGHT); - this.replInstance.editorHeight = height; - this.setState({ editorHeight: height }); - this.editorInstance.resize(); - } - }; - private onMouseUp = (_e) => { - this.setState({ isDraggingDragBar: false }); - }; - componentDidMount() { - document.addEventListener('mousemove', this.onMouseMove); - document.addEventListener('mouseup', this.onMouseUp); - } - componentWillUnmount() { - document.removeEventListener('mousemove', this.onMouseMove); - document.removeEventListener('mouseup', this.onMouseUp); - } - public render() { - const { editorHeight } = this.state; - const outputDivs : JSX.Element[] = []; - const outputStringCount = this.replInstance.outputStrings.length; - for (let i = 0; i < outputStringCount; i++) { - const str = this.replInstance.outputStrings[i]; - if (str.outputMethod === 'richtext') { - if (str.color === '') { - outputDivs.push(
); - } else { - outputDivs.push(
); - } - } else if (str.color === '') { - outputDivs.push(
{ str.content }
); - } else { - outputDivs.push(
{ str.content }
); - } - } - return ( -
-