Recipes
Each recipe is a complete program. The console ones were run and their output is shown; the window ones were type-checked.
Spawn from a definition during a phase
Inside a phase the world refuses direct structural changes, so a spawner defers them. The spawn phase has flushAfter: true, so new enemies are visible to count in the same tick.
ts
import { ComponentKey, DefinitionRegistry, Entity, PhaseSchedule, ResolvedDefinition, World } from "gamekit";
class Enemy {
kind: string;
hp: int;
constructor(kind: string, hp: int) {
this.kind = kind;
this.hp = hp;
}
}
const ENEMY: ComponentKey<Enemy> = new ComponentKey<Enemy>("enemy");
const defs: DefinitionRegistry = new DefinitionRegistry();
defs.define("slime", { hp: 3 });
defs.define("big_slime", { hp: 9 }, ["slime"]);
const world: World = new World();
world.registerStore(ENEMY);
const schedule: PhaseSchedule = new PhaseSchedule();
schedule.addPhase("spawn", [], true); // flushAfter: deferred work lands at the end of the phase
schedule.addPhase("count", ["spawn"]);
schedule.addSystem("spawn", (w: World, dt: float, tick: int): void => {
// Inside a phase the world rejects direct structural changes. Defer them instead.
const e: Entity = w.deferCreateEntity();
const enemy: Enemy = defs.instantiate("big_slime",
(def: ResolvedDefinition, context: any): Enemy => new Enemy(def.id, def.data.hp as int)) as Enemy;
w.deferPutComponent(e, ENEMY, enemy);
});
schedule.addSystem("count", (w: World, dt: float, tick: int): void => {
let total: int = 0;
w.query(ENEMY).each((e: Entity, enemy: Enemy): void => { total += enemy.hp; });
console.log("tick " + tick + ": " + w.entityCount + " enemies, " + total + " hp");
});
for (let tick: int = 0; tick < 3; tick++) {
schedule.run(world, 0.1, tick);
}text
tick 0: 1 enemies, 9 hp
tick 1: 2 enemies, 18 hp
tick 2: 3 enemies, 27 hpMore: World, queries and systems, Definitions and prefabs.
A cooldown in a system
A Gauge per turret fills with the tick's dt; the system fires when it reports full.
ts
import { ComponentKey, Entity, Gauge, PhaseSchedule, World } from "gamekit";
class Turret {
reload: Gauge = new Gauge(1.0, 2.0); // full after 0.5 s of ticks
shots: int = 0;
}
const TURRET: ComponentKey<Turret> = new ComponentKey<Turret>("turret");
const world: World = new World();
world.registerStore(TURRET);
world.put(world.createEntity(), TURRET, new Turret());
const schedule: PhaseSchedule = new PhaseSchedule();
schedule.addPhase("simulate", [], true);
schedule.addSystem("simulate", (w: World, dt: float, tick: int): void => {
w.query(TURRET).each((e: Entity, t: Turret): void => {
if (t.reload.advance(dt)) {
t.reload.consume(1.0);
t.shots += 1;
}
});
});
for (let tick: int = 0; tick < 30; tick++) {
schedule.run(world, 0.1, tick); // 3 seconds at 10 ticks per second
}
world.query(TURRET).each((e: Entity, t: Turret): void => {
console.log("shots in 3 s: " + t.shots);
});text
shots in 3 s: 6More: Gauges, picks and spatial helpers.
Pause the world with a pause scene
Pushing a scene pauses the node of the scene below. A WorldTicker on that node stops with it, and starts again when the pause scene is popped.
ts
import { Node } from "willow";
import { AppScene, PhaseSchedule, SceneStack, World, WorldTicker } from "gamekit";
class PlayScene extends AppScene {
world: World = new World();
schedule: PhaseSchedule = new PhaseSchedule();
ticker: WorldTicker;
constructor() {
super("play");
const root: Node = new Node("play");
engine.root2d.add(root);
this.attachNode(root);
this.schedule.addPhase("simulate", [], true);
this.ticker = new WorldTicker(root, this.world, this.schedule, App.tickDelta);
}
}
class PauseScene extends AppScene {
constructor() {
super("pause");
const root: Node = new Node("pause");
engine.root2d.add(root);
this.attachNode(root);
}
}
const scenes: SceneStack = new SceneStack();
const play: PlayScene = new PlayScene();
await scenes.start(play);
App.onFrame((dt: float): void => {
if (!Keyboard.isPressed(Key.ESCAPE) || scenes.transitioning) { return; }
if (scenes.top === play) {
scenes.push(new PauseScene()); // pauses play's node, so its world stops ticking
} else {
scenes.pop(); // play's node resumes, and so does its world
}
});More: Scenes, WorldTicker.
Replay recorded input
Record each tick's ActionFrame, then feed the recording back through the same step function. The same frames give the same result.
ts
import { ActionFrame, ActionState, InputRecorder, InputReplay } from "gamekit";
import { SimulationClock } from "gamekit/src/testing";
class Runner {
x: float = 0.0;
}
// The same step function for live play and for replay.
function step(runner: Runner, frame: ActionFrame, dt: float): void {
runner.x += frame.axis("move_x") * 100.0 * dt;
}
// 1. A "live" run records each tick's frame.
const recorder: InputRecorder = new InputRecorder();
const live: Runner = new Runner();
const liveClock: SimulationClock = new SimulationClock(0.1, 8, (dt: float, tick: int): void => {
const axis: float = tick < 5 ? 1.0 : -0.5;
const frame: ActionFrame = new ActionFrame([new ActionState("move_x", true, false, false, false, axis)]);
recorder.record(frame);
step(live, frame, dt);
});
liveClock.advance(1.0);
// 2. A replay feeds the recording back, one frame per tick.
const replay: InputReplay = new InputReplay(recorder.recording());
const again: Runner = new Runner();
const replayClock: SimulationClock = new SimulationClock(0.1, 8, (dt: float, tick: int): void => {
if (!replay.finished) {
step(again, replay.next(), dt);
}
});
replayClock.advance(1.0);
console.log("live " + live.x + ", replay " + again.x + ", same: " + (live.x == again.x));text
live 35, replay 35, same: trueMore: Input actions, Testing and debugging.
Save a world and restore it
Save a component's data to a slot, then build a fresh world from it.
ts
import { Codec, Codecs, ComponentKey, Entity, MemoryByteStorage, RecordField, SaveResult, SaveSchema, SaveSlotManager, SaveSlotMetadata, World } from "gamekit";
class Hero {
level: int;
gold: int;
constructor(level: int, gold: int) {
this.level = level;
this.gold = gold;
}
}
const HERO: ComponentKey<Hero> = new ComponentKey<Hero>("hero");
const heroCodec: Codec<Hero> = Codecs.record<Hero>(
[new RecordField("level", Codecs.int()), new RecordField("gold", Codecs.int())],
(f: any): Hero => new Hero(f.level as int, f.gold as int));
const slots: SaveSlotManager<Hero> = new SaveSlotManager<Hero>(
new MemoryByteStorage(), new SaveSchema<Hero>("hero", 1, heroCodec));
async function main(): Promise<void> {
// Play a little, then save the hero component.
const world: World = new World();
world.registerStore(HERO);
const hero: Entity = world.createEntity();
world.put(hero, HERO, new Hero(4, 120));
await slots.saveSlot("main", world.getRequired(hero, HERO), new SaveSlotMetadata("main", "hero", 1));
world.dispose();
// Later: a fresh world, filled from the save.
const loaded: SaveResult<Hero> = await slots.loadSlot("main");
if (!loaded.isOk) {
console.log("could not load: " + loaded.kind);
return;
}
const fresh: World = new World();
fresh.registerStore(HERO);
fresh.put(fresh.createEntity(), HERO, loaded.value as Hero);
fresh.query(HERO).each((e: Entity, h: Hero): void => {
console.log("restored level " + h.level + " with " + h.gold + " gold");
});
}
await main();text
restored level 4 with 120 goldMore: Saves and settings.
A purchase that cannot half-happen
A condition refuses the purchase when gold is short; the staged mutation either commits or rolls back.
ts
import { Action, ActionContext, ActionRequest, ActionResult, Condition, ConditionResult } from "gamekit";
class Wallet {
gold: int = 30;
}
class Price {
cost: int;
constructor(cost: int) { this.cost = cost; }
}
// Conditions explain themselves when they fail.
const canAfford: Condition<ActionContext<Price>> = new Condition<ActionContext<Price>>(
"can-afford", "not enough gold",
(ctx: ActionContext<Price>): boolean => (ctx.actor as Wallet).gold >= ctx.payload.cost);
// An action validates, stages mutations, then commits them all or none.
const buy: Action<Price, any> = new Action<Price, any>("buy", "buys an item");
buy.addCondition(canAfford);
buy.validate((ctx: ActionContext<Price>): void => {
const wallet: Wallet = ctx.actor as Wallet;
const cost: int = ctx.payload.cost;
ctx.stageMutation("pay",
(): void => { wallet.gold -= cost; },
(): void => { wallet.gold += cost; });
});
const wallet: Wallet = new Wallet();
const first: ActionResult<any> = buy.execute(new ActionRequest<Price>("buy", wallet, null, new Price(20)));
const second: ActionResult<any> = buy.execute(new ActionRequest<Price>("buy", wallet, null, new Price(20)));
console.log("first: " + first.status + ", second: " + second.status + " (" + (second.reason ?? "") + ")");
console.log("gold left: " + wallet.gold);
// Conditions compose.
const positive: Condition<int> = new Condition<int>("positive", "must be above zero", (n: int): boolean => n > 0);
const even: Condition<int> = new Condition<int>("even", "must be even", (n: int): boolean => n % 2 == 0);
const both: ConditionResult = Condition.all<int>([positive, even]).evaluate(3);
console.log("3 passes: " + both.passed + ", reason: " + both.reason);text
first: committed, second: denied (not enough gold)
gold left: 10
3 passes: false, reason: must be even