Skip to content

Commit

Permalink
support saving and loading checkpoints (#113)
Browse files Browse the repository at this point in the history
* partial support for checkpoints

still need to store them in a separate key in localStorage (& clear them when the level changes)

* 👕 lint

* fix tests

* save checkpoint data to different localStorage item
  • Loading branch information
philschatz authored Feb 28, 2019
1 parent a9c1bd1 commit d4ac709
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 79 deletions.
10 changes: 5 additions & 5 deletions src/browser/SyncTableEngine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import InputWatcher from '../browser/InputWatcher'
import ResizeWatcher from '../browser/ResizeWatcher'
import { GameEngine } from '../engine'
import { CellSaveState, GameEngine } from '../engine'
import { LEVEL_TYPE } from '../parser/astTypes'
import Parser from '../parser/parser'
import TableUI from '../ui/table'
Expand Down Expand Up @@ -43,12 +43,12 @@ class SubTableEngine {

this.tableUI = new TableUI(table, handler)
}
public setGame(code: string, levelNum: number) {
public setGame(code: string, levelNum: number, checkpoint: Optional<CellSaveState>) {
const { data } = Parser.parse(code)
this.engine = new GameEngine(data, this.tableUI)

this.tableUI.onGameChange(data)
this.engine.setLevel(levelNum)
this.engine.setLevel(levelNum, checkpoint)

if (data.metadata.keyRepeatInterval) {
this.inputWatcher.setKeyRepeatInterval(data.metadata.keyRepeatInterval)
Expand Down Expand Up @@ -108,8 +108,8 @@ export default class SyncTableEngine implements Engineish {
this.table.addEventListener('blur', this.boundPause)
this.table.addEventListener('focus', this.boundResume)
}
public setGame(source: string, level: number = 0) {
this.subEngine.setGame(source, level)
public setGame(source: string, level: number = 0, checkpoint: Optional<CellSaveState>) {
this.subEngine.setGame(source, level, checkpoint)

const engine = this.subEngine.getEngine()
if (engine.getCurrentLevel().type === LEVEL_TYPE.MAP) {
Expand Down
9 changes: 5 additions & 4 deletions src/browser/WebworkerTableEngine.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CellSaveState } from '../engine'
import { GameData } from '../models/game'
import { Dimension } from '../models/metadata'
import { A11Y_MESSAGE, A11Y_MESSAGE_TYPE } from '../models/rule'
Expand All @@ -15,7 +16,7 @@ import { Cellish,
pollingPromise,
PuzzlescriptWorker,
RULE_DIRECTION,
WorkerResponse} from '../util'
WorkerResponse } from '../util'
import InputWatcher from './InputWatcher'
import ResizeWatcher from './ResizeWatcher'

Expand Down Expand Up @@ -91,8 +92,8 @@ export default class WebworkerTableEngine implements Engineish {

this.inputInterval = window.setInterval(this.pollInputWatcher, 10)
}
public setGame(code: string, level: number) {
this.worker.postMessage({ type: MESSAGE_TYPE.ON_GAME_CHANGE, code, level })
public setGame(code: string, level: number, checkpoint: Optional<CellSaveState>) {
this.worker.postMessage({ type: MESSAGE_TYPE.ON_GAME_CHANGE, code, level, checkpoint })
}

public dispose() {
Expand Down Expand Up @@ -160,7 +161,7 @@ export default class WebworkerTableEngine implements Engineish {
this.ui.onPress(data.direction)
break
case MESSAGE_TYPE.ON_TICK:
this.ui.onTick(new Set(data.changedCells.map((x) => this.convertToCellish(x))), data.hasAgain, this.convertToA11yMessages(data.a11yMessages))
this.ui.onTick(new Set(data.changedCells.map((x) => this.convertToCellish(x))), data.checkpoint, data.hasAgain, this.convertToA11yMessages(data.a11yMessages))
break
case MESSAGE_TYPE.ON_SOUND:
await this.ui.onSound({ soundCode: data.soundCode })
Expand Down
3 changes: 2 additions & 1 deletion src/cli/playGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,8 @@ async function playGame(data: GameData, currentLevelNum: number, recordings: ISa
}
})
TerminalUI.clearScreen()
engine.setLevel(data.levels.indexOf(level))
// TODO: Support saving and loading checkpoints in the CLI
engine.setLevel(data.levels.indexOf(level), null/*no checkpoint*/)

function restartLevel() {
engine.press(INPUT_BUTTON.RESTART)
Expand Down
2 changes: 1 addition & 1 deletion src/cli/runGames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async function run() {
startTime = Date.now()
const engine = new GameEngine(data, TerminalUI)
const levelNum = data.levels.indexOf(currentLevel)
engine.setLevel(levelNum)
engine.setLevel(levelNum, null/*no checkpoint*/)
logger.debug(() => `\n\nStart playing "${data.title}". Level ${levelNum}`)

logger.info(() => `Loading Cells into the level took ${Date.now() - startTime}ms`)
Expand Down
42 changes: 28 additions & 14 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface ITickResult {
changedCells: Set<Cell>,
didWinGame: boolean,
didLevelChange: boolean,
wasAgainTick: boolean
wasAgainTick: boolean,
}

type Snapshot = Array<Array<Set<GameSprite>>>
Expand Down Expand Up @@ -464,6 +464,7 @@ export class LevelEngine extends EventEmitter2 {
changedCells: new Set(this.getCells()),
soundToPlay: null,
messageToShow: null,
hasCheckpoint: false,
hasRestart: false,
isWinning: false,
mutations: [],
Expand All @@ -476,6 +477,7 @@ export class LevelEngine extends EventEmitter2 {
changedCells: new Set(this.getCells()),
soundToPlay: null,
messageToShow: null,
hasCheckpoint: false,
hasRestart: true,
isWinning: false,
mutations: [],
Expand All @@ -488,6 +490,7 @@ export class LevelEngine extends EventEmitter2 {
// TODO: Handle the commands like RESTART, CANCEL, WIN at this point
let soundToPlay: Optional<SoundItem<IGameTile>> = null
let messageToShow: Optional<string> = null
let hasCheckpoint = false
let hasWinCommand = false
let hasRestart = false
for (const command of ret.commands) {
Expand All @@ -505,9 +508,11 @@ export class LevelEngine extends EventEmitter2 {
case COMMAND_TYPE.WIN:
hasWinCommand = true
break
case COMMAND_TYPE.CHECKPOINT:
hasCheckpoint = true
break
case COMMAND_TYPE.AGAIN:
case COMMAND_TYPE.CANCEL:
case COMMAND_TYPE.CHECKPOINT:
break
default:
throw new Error(`BUG: Unsupported command "${command}"`)
Expand All @@ -520,6 +525,7 @@ export class LevelEngine extends EventEmitter2 {

return {
changedCells: new Set(ret.changedCells.keys()),
hasCheckpoint,
soundToPlay,
messageToShow,
hasRestart,
Expand Down Expand Up @@ -603,6 +609,11 @@ export class LevelEngine extends EventEmitter2 {
return { movedCells, a11yMessages }
}

// Used for UNDO and RESTART
public createSnapshot() {
return this.getCurrentLevel().getCells().map((row) => row.map((cell) => cell.toSnapshot()))
}

private pressDir(direction: INPUT_BUTTON) {
// Should disable keypresses if `AGAIN` is running.
// It is commented because the didSpritesChange logic is not correct.
Expand Down Expand Up @@ -776,16 +787,19 @@ export class LevelEngine extends EventEmitter2 {
}
return {
changedCells: new Set<Cell>(),
checkpoint: null,
commands: new Set<Command<SoundItem<IGameTile>>>(),
evaluatedRules,
mutations: new Set<IMutation>(),
a11yMessages: []
}
}
let checkpoint: Optional<Snapshot> = null
const didCheckpoint = !!allCommands.find((c) => c.type === COMMAND_TYPE.CHECKPOINT)
if (didCheckpoint) {
this.undoStack = []
this.takeSnapshot(this.createSnapshot())
checkpoint = this.createSnapshot()
this.takeSnapshot(checkpoint)
}
// set this only if we did not CANCEL and if some cell changed
const changedCells = setAddAll(setAddAll(changedCellMutations, changedCellsLate), movedCells)
Expand All @@ -811,11 +825,6 @@ export class LevelEngine extends EventEmitter2 {
})
return conditionsSatisfied
}

// Used for UNDO and RESTART
private createSnapshot() {
return this.getCurrentLevel().getCells().map((row) => row.map((cell) => cell.toSnapshot()))
}
private takeSnapshot(snapshot: Snapshot) {
this.undoStack.push(snapshot)
}
Expand Down Expand Up @@ -900,12 +909,15 @@ export class GameEngine {
public hasAgain() {
return this.levelEngine.hasAgain()
}
public setLevel(levelNum: number) {
public setLevel(levelNum: number, checkpoint: Optional<CellSaveState>) {
this.levelEngine.hasAgainThatNeedsToRun = false
this.currentLevelNum = levelNum
const level = this.getGameData().levels[levelNum]
if (level.type === LEVEL_TYPE.MAP) {
this.levelEngine.setLevel(levelNum)
if (checkpoint) {
this.loadSnapshotFromJSON(checkpoint)
}
this.handler.onLevelChange(this.currentLevelNum, this.levelEngine.getCurrentLevel().getCells(), null)
} else {
this.handler.onLevelChange(this.currentLevelNum, null, level.message)
Expand All @@ -921,7 +933,7 @@ export class GameEngine {
this.handler.onWin()
didWinGameInMessage = true
} else {
this.setLevel(this.currentLevelNum + 1)
this.setLevel(this.currentLevelNum + 1, null/*no checkpoint*/)
}
// clear any keys that were pressed
this.levelEngine.pendingPlayerWantsToMove = null
Expand All @@ -944,14 +956,16 @@ export class GameEngine {
}

const previousPending = this.levelEngine.pendingPlayerWantsToMove
const { changedCells, soundToPlay, messageToShow, isWinning, hasRestart, a11yMessages } = this.levelEngine.tick()
const { changedCells, hasCheckpoint, soundToPlay, messageToShow, isWinning, hasRestart, a11yMessages } = this.levelEngine.tick()

if (previousPending && !this.levelEngine.pendingPlayerWantsToMove) {
this.handler.onPress(previousPending)
}

const checkpoint = hasCheckpoint ? this.saveSnapshotToJSON() : null

if (hasRestart) {
this.handler.onTick(changedCells, hasAgain, a11yMessages)
this.handler.onTick(changedCells, checkpoint, hasAgain, a11yMessages)
return {
changedCells,
didWinGame: false,
Expand All @@ -961,14 +975,14 @@ export class GameEngine {
}

hasAgain = this.levelEngine.hasAgain()
this.handler.onTick(changedCells, hasAgain, a11yMessages)
this.handler.onTick(changedCells, checkpoint, hasAgain, a11yMessages)
let didWinGame = false
if (isWinning) {
if (this.currentLevelNum === this.levelEngine.gameData.levels.length - 1) {
didWinGame = true
this.handler.onWin()
} else {
this.setLevel(this.currentLevelNum + 1)
this.setLevel(this.currentLevelNum + 1, null/*no checkpoint*/)
}
}

Expand Down
12 changes: 6 additions & 6 deletions src/index-webworker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'babel-polyfill' // tslint:disable-line:no-implicit-dependencies
import { Cell, GameEngine } from './engine'
import { Cell, CellSaveState, GameEngine } from './engine'
import { GameData } from './models/game'
import { A11Y_MESSAGE, A11Y_MESSAGE_TYPE } from './models/rule'
import { GameSprite } from './models/tile'
Expand All @@ -19,7 +19,7 @@ let lastTick = 0
onmessage = (event: TypedMessageEvent<WorkerMessage>) => {
const msg = event.data
switch (msg.type) {
case MESSAGE_TYPE.ON_GAME_CHANGE: loadGame(msg.code, msg.level); break
case MESSAGE_TYPE.ON_GAME_CHANGE: loadGame(msg.code, msg.level, msg.checkpoint); break
case MESSAGE_TYPE.PAUSE: postMessage({ type: msg.type, payload: pauseGame() }); break
case MESSAGE_TYPE.RESUME: postMessage({ type: msg.type, payload: resumeGame() }); break
case MESSAGE_TYPE.PRESS: postMessage({ type: msg.type, payload: press(msg.button) }); break
Expand Down Expand Up @@ -99,8 +99,8 @@ class Handler implements GameEngineHandler {
public async onSound(sound: Soundish) {
postMessage({ type: MESSAGE_TYPE.ON_SOUND, soundCode: sound.soundCode })
}
public onTick(changedCells: Set<Cellish>, hasAgain: boolean, a11yMessages: Array<A11Y_MESSAGE<Cell, GameSprite>>) {
postMessage({ type: MESSAGE_TYPE.ON_TICK, changedCells: toCellsJson(changedCells), hasAgain, a11yMessages: a11yMessages.map(toA11yMessageJson) })
public onTick(changedCells: Set<Cellish>, checkpoint: Optional<CellSaveState>, hasAgain: boolean, a11yMessages: Array<A11Y_MESSAGE<Cell, GameSprite>>) {
postMessage({ type: MESSAGE_TYPE.ON_TICK, changedCells: toCellsJson(changedCells), checkpoint, hasAgain, a11yMessages: a11yMessages.map(toA11yMessageJson) })
}
public onPause() {
postMessage({ type: MESSAGE_TYPE.ON_PAUSE })
Expand All @@ -110,13 +110,13 @@ class Handler implements GameEngineHandler {
}
}

const loadGame = (code: string, level: number) => {
const loadGame = (code: string, level: number, checkpoint: Optional<CellSaveState>) => {
pauseGame()
previousMessage = '' // clear this dev-invariant-tester field since it is a new game
const { data } = Parser.parse(code)
postMessage({ type: MESSAGE_TYPE.ON_GAME_CHANGE, payload: (new Serializer(data)).toJson() })
currentEngine = new GameEngine(data, new Handler())
currentEngine.setLevel(level)
currentEngine.setLevel(level, checkpoint)
runPlayLoop() // tslint:disable-line:no-floating-promises
}

Expand Down
Loading

0 comments on commit d4ac709

Please sign in to comment.