From 8dacee8f73633131fd68935c1e2493dc4beec837 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sat, 1 Mar 2025 12:36:47 -0700 Subject: updates --- src/App.tsx | 3 +- src/engine/TheAbstractionEngine.ts | 2 + src/engine/components/ComponentNames.ts | 1 + src/engine/components/Control.ts | 6 +- src/engine/components/LambdaTerm.ts | 2 + src/engine/components/Modal.ts | 10 ++ src/engine/components/index.ts | 1 + src/engine/config/sounds.ts | 4 +- src/engine/entities/FunctionApplication.ts | 108 ++++++++----- src/engine/entities/FunctionBox.ts | 99 +++++------- src/engine/entities/LambdaFactory.ts | 239 ++++++----------------------- src/engine/entities/Sign.ts | 59 +++++-- src/engine/systems/Modal.ts | 93 +++++++++++ src/engine/systems/SystemNames.ts | 1 + src/engine/systems/index.ts | 1 + src/engine/utils/CodeEditor.ts | 200 ++++++++++++++++++++++++ src/engine/utils/Modal.ts | 45 ++++++ src/engine/utils/index.ts | 4 +- src/engine/utils/modal.ts | 41 ----- src/engine/utils/tryWrap.ts | 7 + src/interpreter/interpreter.ts | 2 +- 21 files changed, 572 insertions(+), 356 deletions(-) create mode 100644 src/engine/components/Modal.ts create mode 100644 src/engine/systems/Modal.ts create mode 100644 src/engine/utils/CodeEditor.ts create mode 100644 src/engine/utils/Modal.ts delete mode 100644 src/engine/utils/modal.ts create mode 100644 src/engine/utils/tryWrap.ts diff --git a/src/App.tsx b/src/App.tsx index 6bd26b9..b8582de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,8 +6,7 @@ export const App = () => {
- × -

Some text in the Modal..

+
diff --git a/src/engine/TheAbstractionEngine.ts b/src/engine/TheAbstractionEngine.ts index 29bc553..96d078f 100644 --- a/src/engine/TheAbstractionEngine.ts +++ b/src/engine/TheAbstractionEngine.ts @@ -11,6 +11,7 @@ import { Life, Music, Level, + Modal, } from "./systems"; export class TheAbstractionEngine { @@ -33,6 +34,7 @@ export class TheAbstractionEngine { const facingDirectionSystem = new FacingDirection(inputSystem); [ + new Modal(), new Level(LevelNames.LevelSelection), inputSystem, facingDirectionSystem, diff --git a/src/engine/components/ComponentNames.ts b/src/engine/components/ComponentNames.ts index dd50fb3..241fe3f 100644 --- a/src/engine/components/ComponentNames.ts +++ b/src/engine/components/ComponentNames.ts @@ -12,4 +12,5 @@ export namespace ComponentNames { export const Text = "Text"; export const LambdaTerm = "LambdaTerm"; export const Life = "Life"; + export const Modal = "Modal"; } diff --git a/src/engine/components/Control.ts b/src/engine/components/Control.ts index 48983b2..9196e3b 100644 --- a/src/engine/components/Control.ts +++ b/src/engine/components/Control.ts @@ -1,11 +1,7 @@ import { Component, ComponentNames } from "."; export class Control extends Component { - public isControllable: boolean = true; - - constructor(isControllable = true) { + constructor(public isControllable = true) { super(ComponentNames.Control); - - this.isControllable = isControllable; } } diff --git a/src/engine/components/LambdaTerm.ts b/src/engine/components/LambdaTerm.ts index 63e9889..5ac6215 100644 --- a/src/engine/components/LambdaTerm.ts +++ b/src/engine/components/LambdaTerm.ts @@ -1,7 +1,9 @@ import { Component, ComponentNames } from "."; +import { DebrujinifiedLambdaTerm } from "../../interpreter"; export class LambdaTerm extends Component { public code: string; + public last: null | { data?: DebrujinifiedLambdaTerm; error?: any } = null; constructor(code: string) { super(ComponentNames.LambdaTerm); diff --git a/src/engine/components/Modal.ts b/src/engine/components/Modal.ts new file mode 100644 index 0000000..9b4031f --- /dev/null +++ b/src/engine/components/Modal.ts @@ -0,0 +1,10 @@ +import { Component, ComponentNames } from "."; +import { ModalInitState } from "../systems"; + +export class Modal extends Component { + constructor( + public initState: ModalInitState + ) { + super(ComponentNames.Modal); + } +} diff --git a/src/engine/components/index.ts b/src/engine/components/index.ts index 023e73d..d3b2fd0 100644 --- a/src/engine/components/index.ts +++ b/src/engine/components/index.ts @@ -13,3 +13,4 @@ export * from "./GridSpawn"; export * from "./Text"; export * from "./LambdaTerm"; export * from "./Life"; +export * from "./Modal"; diff --git a/src/engine/config/sounds.ts b/src/engine/config/sounds.ts index 5900ef0..46870db 100644 --- a/src/engine/config/sounds.ts +++ b/src/engine/config/sounds.ts @@ -19,7 +19,7 @@ export const LambdaTransformSound: SoundSpec = { volume: 0.3, }; -export const LambdaSave: SoundSpec = { +export const EditorSave: SoundSpec = { name: "lambdaSave", url: "/assets/sound/lambda_save.wav", volume: 0.3, @@ -83,7 +83,7 @@ export const Music: SoundSpec = { export const SOUND_SPECS: SoundSpec[] = [ MovingSound, LambdaTransformSound, - LambdaSave, + EditorSave, Failure, ModalOpen, ModalClose, diff --git a/src/engine/entities/FunctionApplication.ts b/src/engine/entities/FunctionApplication.ts index a266941..f15fcb9 100644 --- a/src/engine/entities/FunctionApplication.ts +++ b/src/engine/entities/FunctionApplication.ts @@ -1,17 +1,13 @@ -import { - Entity, - EntityNames, - FunctionBox, - Key, - Particles, - makeLambdaTermHighlightComponent, -} from "."; +import { Entity, EntityNames, FunctionBox, Key, Particles } from "."; import { BoundingBox, Colliding, ComponentNames, Grid, + Highlight, + Interactable, LambdaTerm, + Modal, Sprite, } from "../components"; import { @@ -26,9 +22,9 @@ import { import { Coord2D, Direction } from "../interfaces"; import { Game } from ".."; import { Grid as GridSystem, SystemNames } from "../systems"; -import { colors } from "../utils"; +import { colors, tryWrap } from "../utils"; import { - DebrujinifiedLambdaTerm, + InvalidLambdaTermError, SymbolTable, emitNamed, interpret, @@ -62,8 +58,8 @@ export class FunctionApplication extends Entity { y: 0, }, dimension, - 0, - ), + 0 + ) ); this.addComponent(new Grid(gridPosition)); @@ -76,13 +72,48 @@ export class FunctionApplication extends Entity { { x: 0, y: 0 }, dimension, FunctionApplication.spriteSpec.msPerFrame, - FunctionApplication.spriteSpec.frames, - ), + FunctionApplication.spriteSpec.frames + ) ); this.addComponent(new Colliding(this.handleCollision.bind(this))); - this.addComponent(makeLambdaTermHighlightComponent(this)); + this.addComponent( + new Highlight(this.onHighlight.bind(this), this.onUnhighlight.bind(this)) + ); + } + + private onHighlight(_direction: Direction) { + this.addComponent(new Interactable(this.interaction.bind(this))); + } + + private interaction() { + const codeConsumer = (_code: string) => { + this.removeComponent(ComponentNames.Modal); + return { consumed: true }; + }; + const { last, code } = this.getComponent( + ComponentNames.LambdaTerm + ); + this.addComponent( + new Modal({ + type: "CODE_EDITOR", + codeInit: { + code, + codeConsumer, + readonly: true, + result: { + error: last?.error && `Error: ${last.error.message}`, + data: last?.data && `Last Result: ${emitNamed(last.data)}`, + }, + }, + }) + ); + } + + private onUnhighlight() { + this.removeComponent(ComponentNames.Modal); + this.removeComponent(ComponentNames.Interactable); } public handleCollision(game: Game, entity: Entity) { @@ -100,7 +131,7 @@ export class FunctionApplication extends Entity { const gridSystem = game.getSystem(SystemNames.Grid); const fail = () => { entityGrid.movingDirection = gridSystem.oppositeDirection( - entityGrid.previousDirection, + entityGrid.previousDirection ); entity.addComponent(entityGrid); @@ -109,18 +140,17 @@ export class FunctionApplication extends Entity { }; const applicationTerm = this.getComponent( - ComponentNames.LambdaTerm, + ComponentNames.LambdaTerm ); const functionTerm = entity.getComponent( - ComponentNames.LambdaTerm, + ComponentNames.LambdaTerm ); const newCode = applicationTerm.code.replace("_INPUT", functionTerm.code); - let result: DebrujinifiedLambdaTerm | null = null; - try { - result = interpret(newCode, this.symbolTable, true); - } catch (e) { - console.error(e); + const result = tryWrap(() => interpret(newCode, this.symbolTable, true)); + applicationTerm.last = result; + if (result.error || !result.data) { + console.error(result.error); fail(); return; } @@ -128,29 +158,34 @@ export class FunctionApplication extends Entity { const { dimension } = gridSystem; const nextPosition = gridSystem.getNewGridPosition( grid.gridPosition, - entityGrid.previousDirection, + entityGrid.previousDirection ); let applicationResultingEntity: Entity | null = null; // this should be its own function - if ("abstraction" in result) { - const code = emitNamed(result); - + const { data } = result; + if ("application" in data) { + // if we get an application that means we didn't interpret correctly. + // this should "not" happen and should be fatal. + throw new InvalidLambdaTermError( + "produced term should not be an application" + ); + } + if ("abstraction" in data) { + const code = emitNamed(data); applicationResultingEntity = new FunctionBox(grid.gridPosition, code); - } else if ("name" in result) { - const { name } = result; + } + if ("name" in data) { + const { name } = data; const entityFactory = APPLICATION_RESULTS[name]; if (entityFactory) { game.addEntity(entityFactory(nextPosition)); } - } else { - fail(); - return; } game.removeEntity(entity.id); if (applicationResultingEntity) { const grid = applicationResultingEntity.getComponent( - ComponentNames.Grid, + ComponentNames.Grid ); grid.movingDirection = entityGrid.previousDirection; applicationResultingEntity.addComponent(grid); @@ -158,7 +193,7 @@ export class FunctionApplication extends Entity { game.addEntity(applicationResultingEntity); } - this.playTransformSound(); + SOUNDS.get(LambdaTransformSound.name)!.play(); const particles = new Particles({ center: gridSystem.gridToScreenPosition(nextPosition), spawnerDimensions: { @@ -183,9 +218,4 @@ export class FunctionApplication extends Entity { }); game.addEntity(particles); } - - private playTransformSound() { - const audio = SOUNDS.get(LambdaTransformSound.name)!; - audio.play(); - } } diff --git a/src/engine/entities/FunctionBox.ts b/src/engine/entities/FunctionBox.ts index 4878c98..9cba029 100644 --- a/src/engine/entities/FunctionBox.ts +++ b/src/engine/entities/FunctionBox.ts @@ -1,13 +1,4 @@ -import { - IMAGES, - Miscellaneous, - ModalClose, - ModalOpen, - SOUNDS, - SPRITE_SPECS, - SpriteSpec, - Sprites, -} from "../config"; +import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config"; import { Entity, EntityNames } from "."; import { BoundingBox, @@ -16,18 +7,21 @@ import { Highlight, Interactable, LambdaTerm, + Modal, Pushable, Sprite, } from "../components"; import { Coord2D } from "../interfaces"; -import { openModal, closeModal } from "../utils"; export class FunctionBox extends Entity { private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( - Sprites.FUNCTION_BOX, + Sprites.FUNCTION_BOX ) as SpriteSpec; - constructor(gridPosition: Coord2D, code: string) { + constructor( + gridPosition: Coord2D, + private readonly code: string + ) { super(EntityNames.FunctionBox); this.addComponent( @@ -40,8 +34,8 @@ export class FunctionBox extends Entity { width: FunctionBox.spriteSpec.width, height: FunctionBox.spriteSpec.height, }, - 0, - ), + 0 + ) ); this.addComponent(new Pushable()); @@ -57,56 +51,43 @@ export class FunctionBox extends Entity { height: FunctionBox.spriteSpec.height, }, FunctionBox.spriteSpec.msPerFrame, - FunctionBox.spriteSpec.frames, - ), + FunctionBox.spriteSpec.frames + ) ); this.addComponent(new LambdaTerm(code)); - this.addComponent(makeLambdaTermHighlightComponent(this)); + this.addComponent( + new Highlight(this.onHighlight.bind(this), this.onUnhighlight.bind(this)) + ); } -} - -export const makeLambdaTermHighlightComponent = ( - entity: Entity, - text?: string, -) => { - const onUnhighlight = () => { - closeModal(); - entity.removeComponent(ComponentNames.Interactable); - }; - - const onHighlight = () => { - let modalOpen = false; - const doModalClose = () => { - SOUNDS.get(ModalClose.name)!.play(); - modalOpen = false; - closeModal(); - }; - - const interaction = () => { - if (modalOpen) { - doModalClose(); - return; - } - const code = - text ?? - entity.getComponent(ComponentNames.LambdaTerm)!.code; - openModal( - `

${code}


`, - ); - modalOpen = true; - SOUNDS.get(ModalOpen.name)!.play(); - - document.getElementById("close")!.addEventListener("click", () => { - doModalClose(); - document.getElementById(Miscellaneous.CANVAS_ID)!.focus(); - }); + private interaction() { + const codeConsumer = (_code: string) => { + this.removeComponent(ComponentNames.Modal); + return { consumed: true }; }; + const { last } = this.getComponent(ComponentNames.LambdaTerm); + this.addComponent( + new Modal({ + type: "CODE_EDITOR", + codeInit: { + code: this.code, + codeConsumer, + readonly: true, + result: { + error: last?.error && `Error: ${last.error.message}`, + }, + }, + }) + ); + } - entity.addComponent(new Interactable(interaction)); - }; + public onHighlight() { + this.addComponent(new Interactable(this.interaction.bind(this))); + } - return new Highlight(onHighlight, onUnhighlight); -}; + public onUnhighlight() { + this.removeComponent(ComponentNames.Interactable); + } +} diff --git a/src/engine/entities/LambdaFactory.ts b/src/engine/entities/LambdaFactory.ts index 770c096..61a3b0a 100644 --- a/src/engine/entities/LambdaFactory.ts +++ b/src/engine/entities/LambdaFactory.ts @@ -1,10 +1,7 @@ import { Failure, IMAGES, - LambdaSave, LambdaTransformSound, - Miscellaneous, - ModalOpen, SOUNDS, SPRITE_SPECS, SpriteSpec, @@ -19,66 +16,19 @@ import { GridSpawn, Highlight, Interactable, + Modal, Sprite, Text, } from "../components"; import { Coord2D, Direction } from "../interfaces"; -import { openModal, closeModal } from "../utils"; -import { - EditorState, - StateField, - StateEffect, - Range, - Extension, -} from "@codemirror/state"; -import { Decoration, EditorView, keymap } from "@codemirror/view"; -import { defaultKeymap } from "@codemirror/commands"; -import rainbowBrackets from "rainbowbrackets"; -import { basicSetup } from "codemirror"; +import { tryWrap } from "../utils"; import { parse } from "../../interpreter"; -interface CodeEditorState { - view: EditorView; - editorElement: HTMLElement; - syntaxError: HTMLElement; - canvas: HTMLCanvasElement; - closeButton: HTMLButtonElement; -} - -const highlightEffect = StateEffect.define[]>(); -const highlightExtension = StateField.define({ - create() { - return Decoration.none; - }, - update(value, transaction) { - value = value.map(transaction.changes); - - for (let effect of transaction.effects) { - if (effect.is(highlightEffect)) - value = value.update({ add: effect.value, sort: true }); - } - - return value; - }, - provide: (f) => EditorView.decorations.from(f), -}); - -const FontSizeTheme = EditorView.theme({ - $: { - fontSize: "16pt", - }, -}); -const FontSizeThemeExtension: Extension = [FontSizeTheme]; -const syntaxErrorDecoration = Decoration.mark({ - class: "syntax-error", -}); - export class LambdaFactory extends Entity { private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( - Sprites.LAMBDA_FACTORY, + Sprites.LAMBDA_FACTORY ) as SpriteSpec; - private codeEditorState: CodeEditorState | null; private spawns: number; private code: string; @@ -87,7 +37,6 @@ export class LambdaFactory extends Entity { this.spawns = spawns; this.code = code; - this.codeEditorState = null; this.addComponent( new BoundingBox( @@ -99,8 +48,8 @@ export class LambdaFactory extends Entity { width: LambdaFactory.spriteSpec.width, height: LambdaFactory.spriteSpec.height, }, - 0, - ), + 0 + ) ); this.addComponent(new Text(spawns.toString())); @@ -110,8 +59,8 @@ export class LambdaFactory extends Entity { this.addComponent( new GridSpawn( this.spawns, - () => new FunctionBox({ x: 0, y: 0 }, this.code), - ), + () => new FunctionBox({ x: 0, y: 0 }, this.code) + ) ); this.addComponent(new Grid(gridPosition)); @@ -125,166 +74,68 @@ export class LambdaFactory extends Entity { height: LambdaFactory.spriteSpec.height, }, LambdaFactory.spriteSpec.msPerFrame, - LambdaFactory.spriteSpec.frames, - ), + LambdaFactory.spriteSpec.frames + ) ); this.addComponent( - new Highlight( - (direction) => this.onHighlight(direction), - () => this.onUnhighlight(), - ), + new Highlight(this.onHighlight.bind(this), this.onUnhighlight.bind(this)) ); } - private onUnhighlight() { - closeModal(); - this.removeComponent(ComponentNames.Interactable); - } - - private spawnNewLambda(direction: Direction) { - try { - parse(this.code); - } catch (e: any) { - SOUNDS.get(Failure.name)!.play(); - return; + private codeConsumer(code: string) { + const parsed = tryWrap(() => parse(code)); + if (parsed.error) { + return { error: parsed.error }; } - - const spawner = this.getComponent(ComponentNames.GridSpawn); - spawner.spawnEntity(direction); - - const textComponent = this.getComponent(ComponentNames.Text); - textComponent.text = spawner.spawnsLeft.toString(); - this.addComponent(textComponent); - - SOUNDS.get(LambdaTransformSound.name)!.play(); - } - - private openCodeEditor() { - const modalContent = - "

"; - openModal(modalContent); - - const startState = EditorState.create({ - doc: this.code, - extensions: [ - basicSetup, - keymap.of(defaultKeymap), - rainbowBrackets(), - highlightExtension, - FontSizeThemeExtension, - ], - }); - - const codeBox = document.getElementById("code")!; - const syntaxError = document.getElementById("syntax-error")!; - const canvas = document.getElementById( - Miscellaneous.CANVAS_ID, - ) as HTMLCanvasElement; - const closeButton = document.getElementById( - "close-modal", - ) as HTMLButtonElement; - closeButton.addEventListener("click", () => this.saveAndCloseCodeEditor()); - - const editorView = new EditorView({ - state: startState, - parent: codeBox, - }); - editorView.focus(); - - this.codeEditorState = { - view: editorView, - editorElement: codeBox, - syntaxError, - canvas, - closeButton, - }; - - SOUNDS.get(ModalOpen.name)!.play(); + this.code = code; + this.removeComponent(ComponentNames.Modal); + return { consumed: true }; } - private refreshCodeEditorText(text: string) { - if (!this.codeEditorState) { + private onHighlight(direction: Direction) { + if (direction === Direction.LEFT || direction === Direction.RIGHT) { + this.addComponent(new Interactable(() => this.spawnNewLambda(direction))); return; } - const { view } = this.codeEditorState; - view.dispatch({ - changes: { - from: 0, - to: text.length, - insert: "", - }, - }); - view.dispatch({ - changes: { - from: 0, - to: 0, - insert: text, - }, - }); + this.addComponent(new Interactable(this.interaction.bind(this))); } - private saveAndCloseCodeEditor() { - if (!this.codeEditorState) { + private interaction() { + if (this.hasComponent(ComponentNames.Modal)) { return; } - - const { canvas, view, editorElement, syntaxError } = this.codeEditorState; - const text = view.state.doc.toString(); - this.refreshCodeEditorText(text); - syntaxError.innerText = ""; - - try { - parse(text); - } catch (e: any) { - if (!e.location) { - return; - } - const { - location: { - start: { offset: start }, - end: { offset: end }, + this.addComponent( + new Modal({ + type: "CODE_EDITOR", + codeInit: { + code: this.code, + codeConsumer: this.codeConsumer.bind(this), }, - } = e; - - view.dispatch({ - effects: highlightEffect.of([ - syntaxErrorDecoration.range(start === end ? start - 1 : start, end), - ]), - }); - - syntaxError.innerText = e.message; - SOUNDS.get(Failure.name)!.play(); - return; - } - - this.code = text; - - view.destroy(); - editorElement.innerHTML = ""; - this.codeEditorState = null; - closeModal(); + }) + ); + } - canvas.focus(); - SOUNDS.get(LambdaSave.name)!.play(); + private onUnhighlight() { + this.removeComponent(ComponentNames.Modal); + this.removeComponent(ComponentNames.Interactable); } - private onHighlight(direction: Direction) { - if (direction === Direction.LEFT || direction === Direction.RIGHT) { - this.addComponent(new Interactable(() => this.spawnNewLambda(direction))); + private spawnNewLambda(direction: Direction) { + const parsed = tryWrap(() => parse(this.code)); + if (parsed.error) { + SOUNDS.get(Failure.name)!.play(); return; } - const interaction = () => { - if (this.codeEditorState) { - this.saveAndCloseCodeEditor(); - return; - } + const spawner = this.getComponent(ComponentNames.GridSpawn); + spawner.spawnEntity(direction); - this.openCodeEditor(); - }; + const textComponent = this.getComponent(ComponentNames.Text); + textComponent.text = spawner.spawnsLeft.toString(); + this.addComponent(textComponent); - this.addComponent(new Interactable(interaction)); + SOUNDS.get(LambdaTransformSound.name)!.play(); } } diff --git a/src/engine/entities/Sign.ts b/src/engine/entities/Sign.ts index 45f2986..c85ad40 100644 --- a/src/engine/entities/Sign.ts +++ b/src/engine/entities/Sign.ts @@ -1,18 +1,27 @@ -import { Entity, EntityNames, makeLambdaTermHighlightComponent } from "."; -import { BoundingBox, Colliding, Grid, Sprite } from "../components"; +import { Entity, EntityNames } from "."; +import { + BoundingBox, + Colliding, + ComponentNames, + Grid, + Highlight, + Interactable, + Modal, + Sprite, +} from "../components"; import { IMAGES, SPRITE_SPECS, SpriteSpec, Sprites } from "../config"; import { Coord2D } from "../interfaces"; export class Sign extends Entity { private static spriteSpec: SpriteSpec = SPRITE_SPECS.get( - Sprites.SIGN, + Sprites.SIGN ) as SpriteSpec; - private text: string; - - constructor(text: string, gridPosition: Coord2D) { + constructor( + private readonly text: string, + gridPosition: Coord2D + ) { super(EntityNames.Sign); - this.text = text; const dimension = { width: Sign.spriteSpec.width, @@ -25,8 +34,8 @@ export class Sign extends Entity { { x: 0, y: 0 }, dimension, Sign.spriteSpec.msPerFrame, - Sign.spriteSpec.frames, - ), + Sign.spriteSpec.frames + ) ); this.addComponent( @@ -36,14 +45,40 @@ export class Sign extends Entity { y: 0, }, dimension, - 0, - ), + 0 + ) ); this.addComponent(new Grid(gridPosition)); this.addComponent(new Colliding()); - this.addComponent(makeLambdaTermHighlightComponent(this, this.text)); + this.addComponent( + new Highlight(this.onHighlight.bind(this), this.onUnhighlight.bind(this)) + ); + } + + private onHighlight() { + this.addComponent(new Interactable(this.interaction.bind(this))); + } + + private onUnhighlight() { + this.removeComponent(ComponentNames.Modal); + this.removeComponent(ComponentNames.Interactable); + } + + private interaction() { + if (this.hasComponent(ComponentNames.Modal)) { + this.removeComponent(ComponentNames.Modal); + return; + } + this.addComponent( + new Modal({ + type: "CONTENT", + contentInit: { + content: `

${this.text}

`, + }, + }) + ); } } diff --git a/src/engine/systems/Modal.ts b/src/engine/systems/Modal.ts new file mode 100644 index 0000000..7643a73 --- /dev/null +++ b/src/engine/systems/Modal.ts @@ -0,0 +1,93 @@ +import { Game } from ".."; +import { System, SystemNames } from "."; +import { Miscellaneous, ModalClose, ModalOpen, SOUNDS } from "../config"; +import { ComponentNames, Modal as ModalComponent } from "../components"; +import { Entity } from "../entities"; +import { + CodeConsumer, + CodeEditorInstance, + CodeEditorSingleton, + ModalInstance, + ModalSingleton, +} from "../utils"; + +export interface ModalInitState { + type: "CONTENT" | "CODE_EDITOR"; + contentInit?: { + content: string; + }; + codeInit?: { + code: string; + codeConsumer: CodeConsumer; + readonly?: boolean; + result?: { data?: string; error?: string }; + }; +} + +export class Modal extends System { + private openingEntity: null | Entity = null; + private modalInstance: ModalInstance = ModalSingleton; + private codeEditorInstance: CodeEditorInstance = CodeEditorSingleton; + + constructor() { + super(SystemNames.Modal); + } + + public update(_dt: number, game: Game) { + if (this.openingEntity) { + if (this.openingEntity.hasComponent(ComponentNames.Modal)) { + return; + } + this.closeCallback(); + } + + game.forEachEntityWithComponent(ComponentNames.Modal, (entity) => { + const modalComponent = entity.getComponent( + ComponentNames.Modal + ); + if (this.openingEntity) { + return; + } + + this.openingEntity = entity; + SOUNDS.get(ModalOpen.name)!.play(); + + if (modalComponent.initState.type === "CONTENT") { + const content = ` +
+
${modalComponent.initState.contentInit!.content}
+
+ +
`; + this.modalInstance.open(content); + document + .getElementById("close") + ?.addEventListener("click", this.closeCallback.bind(this)); + } + + if (modalComponent.initState.type === "CODE_EDITOR") { + this.codeEditorInstance.open( + modalComponent.initState.codeInit!.code, + (code) => { + const result = + modalComponent.initState.codeInit!.codeConsumer(code); + if (result.consumed) { + this.closeCallback(); + } + return result; + }, + !!modalComponent.initState.codeInit!.readonly, + modalComponent.initState.codeInit!.result, + ); + } + }); + } + + private closeCallback() { + this.openingEntity = null; + this.modalInstance.vanish(); + this.codeEditorInstance.close(); + document.getElementById(Miscellaneous.CANVAS_ID)!.focus(); + SOUNDS.get(ModalClose.name)!.play(); + } +} diff --git a/src/engine/systems/SystemNames.ts b/src/engine/systems/SystemNames.ts index 63f83a4..430ee48 100644 --- a/src/engine/systems/SystemNames.ts +++ b/src/engine/systems/SystemNames.ts @@ -8,4 +8,5 @@ export namespace SystemNames { export const Life = "Life"; export const Music = "Music"; export const Level = "Level"; + export const Modal = "Modal"; } diff --git a/src/engine/systems/index.ts b/src/engine/systems/index.ts index 29dbc7e..75b548d 100644 --- a/src/engine/systems/index.ts +++ b/src/engine/systems/index.ts @@ -9,3 +9,4 @@ export * from "./Collision"; export * from "./Life"; export * from "./Music"; export * from "./Level"; +export * from "./Modal"; \ No newline at end of file diff --git a/src/engine/utils/CodeEditor.ts b/src/engine/utils/CodeEditor.ts new file mode 100644 index 0000000..e13d1d4 --- /dev/null +++ b/src/engine/utils/CodeEditor.ts @@ -0,0 +1,200 @@ +import { + EditorState, + StateField, + StateEffect, + Range, + Extension, +} from "@codemirror/state"; +import { Decoration, EditorView, keymap } from "@codemirror/view"; +import { defaultKeymap } from "@codemirror/commands"; +import rainbowBrackets from "rainbowbrackets"; +import { basicSetup } from "codemirror"; +import { ModalInstance, ModalSingleton } from "."; +import { EditorSave, Failure, ModalOpen, SOUNDS } from "../config"; + +export interface CodeEditorError extends Error { + location: { + start: { offset: number }; + end: { offset: number }; + }; +} + +export type CodeConsumer = (code: string) => { + consumed?: boolean; + error?: CodeEditorError; +}; + +interface CodeEditorState { + view: EditorView; + editorElement: HTMLElement; + errorElement: HTMLElement; + resultElement: HTMLElement; + closeButton: HTMLButtonElement; + codeConsumer: CodeConsumer; +} + +export class CodeEditorInstance { + constructor( + private modalInstance: ModalInstance = ModalSingleton, + private codeEditorState: CodeEditorState | null = null + ) {} + + public close() { + if (!this.codeEditorState) { + return; + } + + this.codeEditorState.view.destroy(); + this.codeEditorState = null; + + SOUNDS.get(EditorSave.name)!.play(); + } + + public open( + initCode: string, + codeConsumer: CodeEditorState["codeConsumer"], + readonly: boolean = false, + initResult: { data?: string; error?: string } = {}, + ) { + if (this.codeEditorState) { + throw new Error("code editor instance is already owned."); + } + const modalContent = ` +
+
+
+

+

+ +
+ `; + this.modalInstance.open(modalContent); + + const startState = EditorState.create({ + doc: initCode, + extensions: [ + basicSetup, + keymap.of(defaultKeymap), + rainbowBrackets(), + highlightExtension, + FontSizeThemeExtension, + EditorState.readOnly.of(readonly), + ], + }); + + const codeBox = document.getElementById("code")!; + const errorElement = document.getElementById("error")!; + const resultElement = document.getElementById("result")!; + + const closeButton = document.getElementById( + "close-modal" + ) as HTMLButtonElement; + closeButton.addEventListener("click", () => this.onSave()); + + const editorView = new EditorView({ + state: startState, + parent: codeBox, + }); + editorView.focus(); + + this.codeEditorState = { + view: editorView, + editorElement: codeBox, + errorElement, + resultElement, + closeButton, + codeConsumer, + }; + this.setResult(initResult); + } + + private refreshCodeEditorText(text: string) { + if (!this.codeEditorState) { + return; + } + const { view } = this.codeEditorState; + + view.dispatch({ + changes: { + from: 0, + to: text.length, + insert: "", + }, + }); + view.dispatch({ + changes: { + from: 0, + to: 0, + insert: text, + }, + }); + } + + private onSave() { + if (!this.codeEditorState) { + throw new Error(); + } + + const { view } = this.codeEditorState; + const code = view.state.doc.toString(); + this.refreshCodeEditorText(code); + + const valid = this.codeEditorState.codeConsumer(code); + if (!valid.error) { + return; + } + + const { + location: { + start: { offset: start }, + end: { offset: end }, + }, + message, + } = valid.error; + + view.dispatch({ + effects: highlightEffect.of([ + syntaxErrorDecoration.range(start === end ? start - 1 : start, end), + ]), + }); + + this.setResult({ error: message }); + SOUNDS.get(Failure.name)!.play(); + } + + private setResult(result: { data?: string; error?: string }) { + if (!this.codeEditorState) return; + this.codeEditorState.resultElement.innerText = result.data ?? ""; + this.codeEditorState.errorElement.innerText = result.error ?? ""; + } +} + +const highlightEffect = StateEffect.define[]>(); +const highlightExtension = StateField.define({ + create() { + return Decoration.none; + }, + update(value, transaction) { + value = value.map(transaction.changes); + + for (let effect of transaction.effects) { + if (effect.is(highlightEffect)) + value = value.update({ add: effect.value, sort: true }); + } + + return value; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +const FontSizeTheme = EditorView.theme({ + $: { + fontSize: "14pt", + }, +}); +const FontSizeThemeExtension: Extension = [FontSizeTheme]; +const syntaxErrorDecoration = Decoration.mark({ + class: "syntax-error", +}); + +export const CodeEditorSingleton = new CodeEditorInstance(); diff --git a/src/engine/utils/Modal.ts b/src/engine/utils/Modal.ts new file mode 100644 index 0000000..d46cade --- /dev/null +++ b/src/engine/utils/Modal.ts @@ -0,0 +1,45 @@ +import { Miscellaneous } from "../config"; + +export class ModalInstance { + private modalOpen: boolean = false; + private elementId: string = Miscellaneous.MODAL_ID; + private contentId: string = Miscellaneous.MODAL_CONTENT_ID; + + public open(content: string) { + const modal = document.getElementById(this.elementId); + const modalContent = document.getElementById(this.contentId); + if (!modal || this.modalOpen) { + return; + } + + this.modalOpen = true; + modal.style.display = "flex"; + modal.style.animation = "fadeIn 0.25s"; + + modalContent!.innerHTML = content; + modalContent!.style.animation = "scaleUp 0.25s"; + } + + public vanish(): Promise { + const modal = document.getElementById(this.elementId); + const modalContent = document.getElementById(this.contentId); + return new Promise((res, _rej) => { + if (!(modal && this.modalOpen && modalContent)) { + res(); + return; + } + modal.style.animation = "fadeOut 0.25s"; + modalContent.style.animation = "scaleDown 0.25s"; + + setTimeout(() => { + modalContent.innerHTML = ""; + modal.style.display = "none"; + + this.modalOpen = false; + res(); + }, 200); + }); + } +} + +export const ModalSingleton = new ModalInstance(); \ No newline at end of file diff --git a/src/engine/utils/index.ts b/src/engine/utils/index.ts index 48b94b8..ffeef7a 100644 --- a/src/engine/utils/index.ts +++ b/src/engine/utils/index.ts @@ -1,6 +1,8 @@ export * from "./clamp"; export * from "./dotProduct"; export * from "./rotateVector"; -export * from "./modal"; export * from "./colors"; export * from "./random"; +export * from './tryWrap'; +export * from "./Modal"; +export * from "./CodeEditor"; \ No newline at end of file diff --git a/src/engine/utils/modal.ts b/src/engine/utils/modal.ts deleted file mode 100644 index a378821..0000000 --- a/src/engine/utils/modal.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Miscellaneous } from "../config"; - -let modalOpen = false; - -export const openModal = ( - content: string, - id = Miscellaneous.MODAL_ID, - contentId = Miscellaneous.MODAL_CONTENT_ID, -) => { - const modal = document.getElementById(id); - const modalContent = document.getElementById(contentId); - if (modal && !modalOpen && modalContent) { - modal.style.display = "flex"; - modal.style.animation = "fadeIn 0.25s"; - - modalContent.innerHTML = content; - modalContent.style.animation = "scaleUp 0.25s"; - - modalOpen = true; - } -}; - -export const closeModal = ( - id = Miscellaneous.MODAL_ID, - contentId = Miscellaneous.MODAL_CONTENT_ID, -) => { - const modal = document.getElementById(id); - const modalContent = document.getElementById(contentId); - - if (modal && modalOpen && modalContent) { - modal.style.animation = "fadeOut 0.25s"; - modalContent.style.animation = "scaleDown 0.25s"; - - setTimeout(() => { - modalContent.innerHTML = ""; - modal.style.display = "none"; - - modalOpen = false; - }, 200); - } -}; diff --git a/src/engine/utils/tryWrap.ts b/src/engine/utils/tryWrap.ts new file mode 100644 index 0000000..12e23ea --- /dev/null +++ b/src/engine/utils/tryWrap.ts @@ -0,0 +1,7 @@ +export const tryWrap = (supplier: () => T): { data?: T; error?: any } => { + try { + return { data: supplier() }; + } catch (error) { + return { error: error as any }; + } +}; diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index e90b8ce..65a848f 100644 --- a/src/interpreter/interpreter.ts +++ b/src/interpreter/interpreter.ts @@ -249,8 +249,8 @@ export const emitNamed = (term: DebrujinifiedLambdaTerm): string => { export const interpret = ( term: string, symbolTable = new SymbolTable(), + allowUnderscores = false, // in our world, underscores should be internal to the game. maxDepth = 15, - allowUnderscores = false ): DebrujinifiedLambdaTerm => { const ast = parse(term, allowUnderscores); const debrujined = debrujinify(ast, symbolTable); -- cgit v1.2.3-70-g09d2