From 3eac6863d6d16bd5ab1c4d3055acf13a4b253a3c Mon Sep 17 00:00:00 2001 From: aNNiMON Date: Sat, 2 Mar 2024 22:54:29 +0200 Subject: [PATCH] Add parser --- src/parser/Parser.ts | 750 ++++++++++++++++++++++++++++++++++++++++ src/parser/ast/index.ts | 13 + src/utils/TextUtils.ts | 7 + 3 files changed, 770 insertions(+) create mode 100644 src/parser/Parser.ts create mode 100644 src/parser/ast/index.ts create mode 100644 src/utils/TextUtils.ts diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts new file mode 100644 index 0000000..cea1b59 --- /dev/null +++ b/src/parser/Parser.ts @@ -0,0 +1,750 @@ +import { TextUtils } from "../utils/TextUtils"; +import { Variables } from "../runtime/Variables"; +import { FadeInfo } from "../view/model/FadeInfo"; +import { Menu } from "../view/model/Menu"; +import { ViewModel } from "../view/model/ViewModel"; +import { Token } from "./Token"; +import { TokenType } from "./TokenType"; +import { + Expression, + ValueExpression, + VariableExpression, + BinaryExpression, + Operator +} from "./ast"; + +export class Parser { + private readonly EOF = new Token("", TokenType.EOF); + + private tokensCount: number + private position: number + private lastPosition: number + private labels: object + private hasEndMenu: boolean + private hasEndIf: boolean + private vm: ViewModel + + constructor(private readonly tokens: Array) { + this.tokens = tokens; + this.tokensCount = tokens.length; + this.position = 0; + this.lastPosition = 0; + this.labels = {}; + /** Optimization, if there no endmenu/endif => don't search them */ + this.hasEndMenu = false; + this.hasEndIf = false; + } + + public getInstance(): Parser { + return this; + } + + public setView(view: ViewModel): void { + this.vm = view + } + + public setPosition(position): void { + this.position = position; + this.next(); + } + + public jumpLabel(label): void { + if (label in this.labels) { + this.position = this.labels[label]; + } + } + + public next(): void { + this.lastPosition = this.position; + + let terminal = false; + let counter = 0; + do { + try { + terminal = this.statement(); + } catch (re) { + console.log("Parser.next() " + re); + if (this.tokens.length === 0) return; + } + // to prevent infinite loop + counter++; + if (counter >= 1000) { + this.position++; + counter = 0; + } + } while (!terminal); + } + + public prevScene(): void { + // To jump to previous scene search SCENE token twice + // as the first occurence will only find a current scene + const currentScene = this.find(TokenType.SCENE, -1); + const pos = this.findFrom(currentScene, TokenType.SCENE, -1); + // Jump to start if no scenes found + if (pos == this.position) this.position = 0; + else this.position = pos; + this.next(); + } + + public nextScene(): void { + this.position = this.find(TokenType.SCENE, 1); + this.next(); + } + + private find(which: TokenType, step: number): number { + return this.findFrom(this.position, which, step); + } + + private findFrom(from: number, which: TokenType, step: number) { + let pos = from; + while (true) { + pos += step; + if (pos < 0 || pos >= this.tokensCount) break; + + const token = this.tokens[pos]; + if (which === token.getType()) return pos; + } + return from; + } + + private statement(): boolean { + const token = this.get(0); + if (this.match(token, TokenType.COMMAND)) return this.command(); + if (this.match(token, TokenType.SCENE)) return this.scene(); + if (this.match(token, TokenType.PLAY)) return this.play(); + if (this.match(token, TokenType.QUEUE)) return this.queue(); + if (this.match(token, TokenType.STOP)) return this.stop(); + if (this.match(token, TokenType.SHOW)) return this.show(); + if (this.match(token, TokenType.HIDE)) return this.hide(); + + if (this.match(token, TokenType.JUMP)) return this.jump(); + if (this.match(token, TokenType.IF)) return this.ifStatement(); + + if (this.lookMatch(1, TokenType.COLON)) { + // menu: + if (this.match(token, TokenType.MENU)) return this.menu(); + + // The rest of menu choice. Skip to ENDMENU. + if (this.match(token, TokenType.TEXT)) { + if (this.hasEndMenu) this.position += this.skipMenu(); + return false; + } + + // The rest of if statement. Skip to ENDIF. + if (this.match(token, TokenType.ELSE)) { + if (this.hasEndIf) this.position += this.skipIf(); + return false; + } + } + + // Text with author name. + if (this.lookMatch(1, TokenType.TEXT) && this.match(token, TokenType.WORD)) { + const whoid = token.getText(); + this.vm.textW(whoid, this.consume(TokenType.TEXT).getText()); + return true; + } + + // Regular text + if (this.match(token, TokenType.TEXT)) { + this.vm.text(token.getText()); + return true; + } + + if (this.match(token, TokenType.EOF)) { + this.vm.finish(); + return true; + } + + if (this.match(token, TokenType.WINDOW)) { + if (this.match0(TokenType.SHOW)) + return this.vm.windowShow(this.matchWithEffect()); + else if (this.match0(TokenType.HIDE)) + return this.vm.windowHide(this.matchWithEffect()); + return false; + } + + if (!TextUtils.isEmpty(this.matchWithEffect())) return false; + + this.position++; + return false; + } + + private command(): boolean { + const token = this.get(0); + + if (this.match(token, TokenType.RENPY_PAUSE)) { + this.consume(TokenType.LPAREN); + const pause = this.consumeDouble(); + let hard = false; + if (!this.lookMatch(0, TokenType.RPAREN)) { + this.consume(TokenType.WORD); // hard + this.consume(TokenType.EQ); + hard = this.consumeBoolean(); + } + this.consume(TokenType.RPAREN); + this.vm.pause(Math.floor(1000 * pause), hard); + return true; + } + + if (this.match(token, TokenType.RENPY_SAY)) { + this.consume(TokenType.LPAREN); + const whoid = this.consume(TokenType.WORD).getText(); + // TODO: this.consume(TokenType.COMMA) + const text = this.consume(TokenType.TEXT).getText(); + // TODO: this.consume(TokenType.COMMA) + this.consume(TokenType.WORD); // interact + this.consume(TokenType.EQ); + const interact = this.consumeBoolean(); + this.consume(TokenType.RPAREN); + this.vm.textW(whoid, text); + return interact; + } + + if (this.match(token, TokenType.PERSISTENT_SPRITE_TIME)) { + this.consume(TokenType.EQ); + this.consume(TokenType.TEXT); + return false; + } + + if (this.match(token, TokenType.PROLOG_TIME) || + this.match(token, TokenType.DAY_TIME) || + this.match(token, TokenType.SUNSET_TIME) || + this.match(token, TokenType.NIGHT_TIME)) { + this.consume(TokenType.LPAREN); + this.consume(TokenType.RPAREN); + return false; + } + + if (this.match(token, TokenType.MAKE_NAMES_KNOWN) || + this.match(token, TokenType.MAKE_NAMES_UNKNOWN)) { + this.consume(TokenType.LPAREN); + this.consume(TokenType.RPAREN); + if (token.getType() === TokenType.MAKE_NAMES_KNOWN) { + this.vm.makeNamesKnown(); + } else { + this.vm.makeNamesUnknown(); + } + return false; + } + + if (this.match(token, TokenType.SET_NAME)) { + this.consume(TokenType.LPAREN); + const whoid = this.consume(TokenType.TEXT).getText(); + // TODO: this.consume(TokenType.COMMA) + const name = this.consume(TokenType.TEXT).getText(); + this.consume(TokenType.RPAREN); + this.vm.meet(whoid, name); + return false; + } + + // Map + if (this.match(token, TokenType.DISABLE_ALL_ZONES)) { + this.consume(TokenType.LPAREN); + this.consume(TokenType.RPAREN); + this.vm.disableAllZones(); + return false; + } + if (this.match(token, TokenType.DISABLE_CURRENT_ZONE)) { + this.consume(TokenType.LPAREN); + this.consume(TokenType.RPAREN); + this.vm.disableCurrentZone(); + return false; + } + if (this.match(token, TokenType.RESET_ZONE)) { + this.consume(TokenType.LPAREN); + const zone = this.consume(TokenType.TEXT).getText(); + this.consume(TokenType.RPAREN); + this.vm.resetZone(zone); + return false; + } + if (this.match(token, TokenType.SET_ZONE)) { + this.consume(TokenType.LPAREN); + const zone = this.consume(TokenType.TEXT).getText(); + // TODO: this.consume(TokenType.COMMA) + const label = this.consume(TokenType.TEXT).getText(); + this.consume(TokenType.RPAREN); + this.vm.setZone(zone, label); + return false; + } + if (this.match(token, TokenType.SHOW_MAP)) { + this.consume(TokenType.LPAREN); + this.consume(TokenType.RPAREN); + this.vm.showMap(); + return true; + } + + if (this.match(token, TokenType.WORD)) { + if (this.match0(TokenType.EQ)) { + // variable = expression + Variables.setVariable(token.getText(), this.expression().eval()); + return false; + } + if (this.lookMatch(1, TokenType.EQ) && this.match0(TokenType.PLUS)) { + // variable += expression + this.consume(TokenType.EQ); + const varValue = Variables.getVariable(token.getText()); + Variables.setVariable(token.getText(), varValue + this.expression().eval()); + return false; + } + if (this.lookMatch(1, TokenType.EQ) && this.match0(TokenType.MINUS)) { + // variable -= expression + this.consume(TokenType.EQ); + const varValue = Variables.getVariable(token.getText()); + Variables.setVariable(token.getText(), varValue - this.expression().eval()); + return false; + } + } + + return false; + } + + private scene(): boolean { + let type = ""; + if (this.match0(TokenType.BG)) type = "bg"; + else if (this.match0(TokenType.CG)) type = "cg"; + else if (this.match0(TokenType.ANIM)) type = "anim"; + + const name = this.consume(TokenType.WORD).getText(); + const effect = this.matchWithEffect(); + this.vm.background(type, name, effect); + return true; + } + + private show(): boolean { + const whoid = this.consume(TokenType.WORD).getText(); + let params = ""; // emotion? cloth? distance? + while (this.lookMatch(0, TokenType.WORD)) { + let text = this.consume(TokenType.WORD).getText(); + params += text; + if (text == "close" || text == "far") break; + } + // Position (left, cleft, ...) + let position = ""; + if (this.match0(TokenType.AT)) { + position = this.consume(TokenType.WORD).getText(); + } + // Alias (to show same character in different positions) + let alias = ""; + if (this.match0(TokenType.AS)) { + alias = this.consume(TokenType.WORD).getText(); + } + const effect = this.matchWithEffect(); + this.vm.sprite(whoid, params, position, alias, effect); + return false; + } + + private hide(): boolean { + const whoid = this.consume(TokenType.WORD).getText(); + const effect = this.matchWithEffect(); + this.vm.hideSprite(whoid, effect); + return false; + } + + private play(): boolean { + if (this.match0(TokenType.MUSIC)) return this.playMusic(); + if (this.match0(TokenType.AMBIENCE)) return this.playAmbience(); + if (this.match0(TokenType.SOUND)) return this.playSound(); + if (this.match0(TokenType.SOUNDLOOP)) return this.playSoundLoop(); + return false; + } + + private playMusic(): boolean { + const name = this.consumeMusicName(); + const fade = this.matchFade(); + this.vm.music(name, fade); + return false; + } + + private playSound(): boolean { + const name = this.consumeMusicName(); + const fade = this.matchFade(); + this.vm.sound(name, fade); + return false; + } + + private playSoundLoop(): boolean { + const name = this.consumeMusicName(); + const fade = this.matchFade(); + this.vm.soundLoop(name, fade); + return false; + } + + private playAmbience(): boolean { + const name = this.consumeMusicName(); + const fade = this.matchFade(); + this.vm.ambience(name, fade); + return false; + } + + private queue(): boolean { + if (this.match0(TokenType.MUSIC)) return this.queueMusic(); + if (this.match0(TokenType.SOUND)) return this.queueSound(); + return false; + } + + private queueMusic(): boolean { + const name = this.consumeMusicName(); + this.vm.addMusicToQueue(name); + return false; + } + + private queueSound(): boolean { + const name = this.consumeMusicName(); + this.vm.addSoundToQueue(name); + return false; + } + + private stop(): boolean { + if (this.match0(TokenType.MUSIC)) { + const fade = this.matchFade(); + this.vm.stopMusic(fade); + } + if (this.match0(TokenType.SOUND)) { + const fade = this.matchFade(); + this.vm.stopSound(fade); + } + if (this.match0(TokenType.SOUNDLOOP)) { + const fade = this.matchFade(); + this.vm.stopSoundLoop(fade); + } + if (this.match0(TokenType.AMBIENCE)) { + const fade = this.matchFade(); + this.vm.stopAmbience(fade); + } + return false; + } + + private menu(): boolean { + // menu: title? + this.consume(TokenType.COLON); + + let title = ""; + if (this.lookMatch(0, TokenType.TEXT) && !this.lookMatch(1, TokenType.COLON)) { + title = this.consume(TokenType.TEXT).getText(); + } + + if (!this.hasEndMenu) return false; + // Search choice elements + const menu = new Menu(title); + let pos = 0; + let level = 1; // menu nesting level + while (true) { + if (this.lookMatch(pos, TokenType.MENU) && this.lookMatch(pos + 1, TokenType.COLON)) { + level++; + pos++; + } + if (this.lookMatch(pos, TokenType.ENDMENU)) { + level--; + // Break on reaching 1-level ENDMENU + if (level <= 0) break; + } + + if (level == 1) { + // Add elements from 1-level menu + if (this.lookMatch(pos, TokenType.TEXT) && this.lookMatch(pos + 1, TokenType.COLON)) { + menu.addItem(this.get(pos).getText(), this.position + pos + 2); + pos++; + } + } + + if (this.lookMatch(pos, TokenType.EOF)) return false; + pos++; + } + + this.vm.menu(menu); + return true; + } + + private jump(): boolean { + const labelName = this.consume(TokenType.WORD).getText(); + this.jumpLabel(labelName); + return false; + } + + private ifStatement(): boolean { + const condition = this.expression(); + this.consume(TokenType.COLON); + + if (!this.hasEndIf) return false; + if (condition.eval() == 0) { + // If false, skip to next ENDIF + let pos = 0; + let level = 1; // block nesting level + while (true) { + if (this.lookMatch(pos, TokenType.IF)) { + level++; + pos++; + } + if (this.lookMatch(pos, TokenType.ELSE)) { + level--; + pos += 2; // skip ELSE and COLON + + // Break on 1-level ELSE + if (level <= 0) break; + } + if (this.lookMatch(pos, TokenType.ENDIF)) { + level--; + // Break on 1-level ENDIF + if (level <= 0) break; + } + + if (this.lookMatch(pos, TokenType.EOF)) return false; + pos++; + } + this.position += pos; + } + return false; + } + + private expression(): Expression { + return this.orTest(); + } + + private orTest(): Expression { + let expression = this.andTest(); + + while (true) { + if (this.match0(TokenType.OR)) { + expression = new BinaryExpression(Operator.BOOLEAN_OR, expression, this.andTest()); + continue; + } + break; + } + + return expression; + } + + private andTest(): Expression { + let expression = this.notTest(); + + while (true) { + if (this.match0(TokenType.AND)) { + expression = new BinaryExpression(Operator.BOOLEAN_AND, expression, this.notTest()); + continue; + } + break; + } + + return expression; + } + + private notTest(): Expression { + if (this.match0(TokenType.NOT)) { + return new ValueExpression(this.notTest().eval() != 0 ? 0 : 1); + } + return this.comparison(); + } + + private comparison(): Expression { + let expression = this.additive(); + + while (true) { + if (this.lookMatch(1, TokenType.EQ)) { + if (this.match0(TokenType.EQ)) { + // == + this.consume(TokenType.EQ); + expression = new BinaryExpression(Operator.EQUALS, expression, this.additive()); + continue; + } + if (this.match0(TokenType.GT)) { + // >= + this.consume(TokenType.EQ); + expression = new BinaryExpression(Operator.GTEQ, expression, this.additive()); + continue; + } + if (this.match0(TokenType.LT)) { + // <= + this.consume(TokenType.EQ); + expression = new BinaryExpression(Operator.LTEQ, expression, this.additive()); + continue; + } + if (this.match0(TokenType.EXCL)) { + // != + this.consume(TokenType.EQ); + expression = new BinaryExpression(Operator.NOTEQUALS, expression, this.additive()); + continue; + } + } + + if (this.match0(TokenType.LT)) { + expression = new BinaryExpression(Operator.LT, expression, this.additive()); + continue; + } + if (this.match0(TokenType.GT)) { + expression = new BinaryExpression(Operator.GT, expression, this.additive()); + continue; + } + break; + } + + return expression; + } + + private additive(): Expression { + let expression = this.unary(); + + while (true) { + if (this.match0(TokenType.PLUS)) { + expression = new BinaryExpression(Operator.ADD, expression, this.unary()); + continue; + } + if (this.match0(TokenType.MINUS)) { + expression = new BinaryExpression(Operator.SUBTRACT, expression, this.unary()); + continue; + } + break; + } + + return expression; + } + + private unary(): Expression { + if (this.match0(TokenType.MINUS)) { + return new ValueExpression(-this.primary().eval()); + } + if (this.match0(TokenType.PLUS)) { + return this.primary(); + } + return this.primary(); + } + + private primary(): Expression { + const current = this.get(0); + if (this.match(current, TokenType.NUMBER)) { + return new ValueExpression(parseFloat(current.getText())); + } + if (this.match(current, TokenType.WORD)) { + return new VariableExpression(current.getText()); + } + if (this.match(current, TokenType.LPAREN)) { + const expr = this.expression(); + this.match0(TokenType.RPAREN); + return expr; + } + throw "Unknown expression"; + } + + private preScan(): void { + // Pre-scan all labels for quick jumping + for (let i = 0; i < this.tokensCount; i++) { + const current = this.tokens[i].getType(); + if (current === TokenType.ENDMENU) { + this.hasEndMenu = true; + } else if (current === TokenType.ENDIF) { + this.hasEndIf = true; + } else if ((current === TokenType.LABEL) && + ((i + 2) < this.tokensCount) && + (this.tokens[i + 2].getType() === TokenType.COLON)) { + // label word : + const token = this.tokens[i + 1]; + if (token.getType() === TokenType.WORD) { + // add next command position + this.labels[token.getText()] = i + 3; + } + i += 2; + } + } + } + + private skipMenu(): number { + let pos = 0; + let level = 1; // menu nesting level + while (true) { + if (this.lookMatch(pos, TokenType.MENU) && this.lookMatch(pos + 1, TokenType.COLON)) { + level++; + pos += 2; + } + if (this.lookMatch(pos, TokenType.ENDMENU)) { + pos++; + level--; + // Break on reaching 1-level ENDMENU + if (level <= 0) break; + } + if (this.lookMatch(pos, TokenType.EOF)) return 0; + pos++; + } + return pos; + } + + private skipIf(): number { + let pos = 0; + let level = 1; + while (true) { + if (this.lookMatch(pos, TokenType.IF)) level++; + else if (this.lookMatch(pos, TokenType.ENDIF)) { + pos++; + level--; + if (level <= 0) break; + } else if (this.lookMatch(pos, TokenType.EOF)) return 0; + pos++; + } + return pos; + } + + private consumeMusicName(): string { + let name = ""; + if (this.lookMatch(1, TokenType.LBRACKET)) { + // music_list["music"] + this.consume(TokenType.WORD); + this.consume(TokenType.LBRACKET); + name = this.consume(TokenType.TEXT).getText(); + this.consume(TokenType.RBRACKET); + } else if (this.lookMatch(0, TokenType.TEXT)) { + name = this.consume(TokenType.TEXT).getText(); + } else { + name = this.consume(TokenType.WORD).getText(); + } + return name; + } + + private matchFade(): FadeInfo { + const result = new FadeInfo(); + if (this.match0(TokenType.FADEIN)) { + result.fadeIn = true; + result.duration = this.consumeDouble(); + } else if (this.match0(TokenType.FADEOUT)) { + result.fadeOut = true; + result.duration = this.consumeDouble(); + } + return result; + } + + private matchWithEffect(): string { + if (this.match0(TokenType.WITH)) { + return this.consume(TokenType.WORD).getText(); + } + return ""; + } + + private consumeDouble(): number { + return parseFloat(this.consume(TokenType.NUMBER).getText()); + } + + private consumeBoolean(): boolean { + return "true" === (this.consume(TokenType.WORD).getText().toLowerCase()); + } + + private match0(type: TokenType): boolean { + return this.match(this.get(0), type); + } + + private match(token: Token, type: TokenType): boolean { + if (type !== token.getType()) return false; + this.position++; + return true; + } + + private consume(type: TokenType): Token { + if (this.get(0).getType() !== type) throw "Expected token type " + type + ", but found " + this.get(0); + return this.tokens[this.position++]; + } + + private lookMatch(pos: number, type: TokenType): boolean { + return (type === this.get(pos).getType()); + } + + private get(offset: number): Token { + if (this.position + offset >= this.tokensCount) return this.EOF; + return this.tokens[this.position + offset]; + } +}; \ No newline at end of file diff --git a/src/parser/ast/index.ts b/src/parser/ast/index.ts new file mode 100644 index 0000000..3d0ffbc --- /dev/null +++ b/src/parser/ast/index.ts @@ -0,0 +1,13 @@ +import { BinaryExpression } from "./BinaryExpression"; +import { Expression } from "./Expression"; +import { Operator } from "./Operator"; +import { ValueExpression } from "./ValueExpression"; +import { VariableExpression } from "./VariableExpression"; + +export { + Expression, + ValueExpression, + VariableExpression, + BinaryExpression, + Operator +}; \ No newline at end of file diff --git a/src/utils/TextUtils.ts b/src/utils/TextUtils.ts new file mode 100644 index 0000000..e5fb9a9 --- /dev/null +++ b/src/utils/TextUtils.ts @@ -0,0 +1,7 @@ +export class TextUtils { + public static isEmpty(str: string|null) { + if (typeof str === 'undefined') return true; + if (str === undefined) return true; + return str.length === 0; + } +} \ No newline at end of file