#!/usr/bin/env node /** * Writes the example game: a small jump and run built entirely from assets, behaviors and the * node graph – no engine code is involved. * * Title screen (a container with a panel, texts and a clickable Start button) * Level (ground, platforms, walls, ten coins, the player and the HUD labels) * Coin (sprite plus trigger collider – placed ten times inside the level container) * Win / Lose (containers shown when the coins are collected or the time is up) * * Screens are containers that are switched on and off with the `enabled` property – the same * switch the inspector shows for every object. Everything else is nodes: gravity and jumping * on the player, timer, coin counter and screen flow on the "Game" behavior. * * Usage: node tools/seed-jumprun.mjs [projectId] [baseUrl] */ import { edge, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun); const SKY = 0x151821; const STONE = 0x3c4454; const GRASS = 0x4a7d3a; const PLAYER = 0x63c74d; const COIN = 0xf5c542; const PANEL = 0x11141c; const BUTTON = 0x63c74d; const WHITE = 0xf2f5f7; const HINT = 0x9aa3b2; const BUTTON_TEXT = 0x10240a; // The camera shows x −320…320, y −180…180; +y is down. /** A solid box: the sprite plus a collider child of the same size. */ const solid = (id, x, y, w, h, color) => [ sceneObject(id, 'box', { x, y, overrides: { width: w, height: h, color } }), sceneObject(`${id}-c`, 'solid', { parentId: id, overrides: { width: w, height: h } }), ]; const text = ( id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}, ) => sceneObject(id, 'label', { name, x, y, z, parentId, overrides: { text: content, fontSize: size, color, align }, }); /** One placed coin: the container object; its content comes from the coin template. */ const coin = (id, x, y) => sceneObject(id, 'coin', { name: 'Coin', x, y }); /** The coin: a sprite with a trigger collider child, so it reports the touch without blocking. */ const coinTemplate = [ sceneObject('coin-body', 'coin-sprite', { z: 1 }), sceneObject('coin-body-c', 'trigger', { parentId: 'coin-body' }), ]; const levelTemplate = [ ...solid('ground', 0, 165, 660, 30, GRASS), ...solid('wall-l', -315, 0, 10, 360, STONE), ...solid('wall-r', 315, 0, 10, 360, STONE), ...solid('p1', -170, 90, 120, 16, STONE), ...solid('p2', -30, 30, 140, 16, STONE), ...solid('p3', 170, 60, 140, 16, STONE), ...solid('p4', 60, -50, 120, 16, STONE), ...solid('p5', -230, -30, 120, 16, STONE), ...solid('p6', 255, -60, 110, 16, STONE), // Ten coins: three on the ground, one above each platform, one in the far corner. coin('c1', -150, 128), coin('c2', 0, 128), coin('c3', 150, 128), coin('c4', -170, 60), coin('c5', -30, 0), coin('c6', 170, 30), coin('c7', 60, -80), coin('c8', -230, -60), coin('c9', 255, -90), coin('c10', 290, 128), sceneObject('player', 'player-sprite', { name: 'Player', x: -270, y: 125, z: 2 }), sceneObject('player-c', 'solid', { parentId: 'player', overrides: { width: 30, height: 40 } }), sceneObject('player-logic', 'player', { parentId: 'player', y: -60 }), // HUD, top left and top right of the camera frame. text('time-label', -300, -160, 'Time 30', { size: 22, align: 'left', name: 'TimeLabel', z: 3 }), text('coin-label', 300, -160, 'Coins 0/10', { size: 22, align: 'right', name: 'CoinLabel', z: 3 }), ]; /** A full-frame panel with a headline, a line of help text and a button carrying a behavior. */ const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId) => [ sceneObject(`${prefix}-panel`, 'box', { overrides: { width: 640, height: 360, color: PANEL } }), text(`${prefix}-title`, 0, -70, headline, { size: 52 }), text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: HINT }), sceneObject(`${prefix}-button`, 'box', { y: 80, z: 1, overrides: { width: 240, height: 60, color: BUTTON }, }), text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: BUTTON_TEXT, parentId: `${prefix}-button`, }), sceneObject(`${prefix}-button-logic`, behaviorId, { parentId: `${prefix}-button`, y: -60 }), ]; const templates = { level: levelTemplate, coin: coinTemplate, title: screenTemplate('title', 'JUMP & RUN', 'Arrow keys to move, Space to jump.', 'START', 'start-button'), win: screenTemplate('win', 'YOU WIN!', 'All ten coins collected in time.', 'PLAY AGAIN', 'restart-button'), lose: screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button'), }; /** * "Player": gravity, walking, jumping and picking coins up – three strands. * * On update vy += gravity·dt, then Move(owner) by (axis·speed, vy)·dt with collisions on. * Blocked vertically while falling means standing: vy back to 0, onGround true. * On key down Space while onGround shoots vy up (negative is up). * On collision Touching a coin adds one to the "Game" behavior's counter and destroys the coin – * the collider reports its body (the sprite), the coin itself is the container above. */ const playerGraph = { nodes: [ node('p-upd', 'OnUpdate', 0, 0), node('p-owner', 'Owner', 0, 140), node('p-axis', 'AxisInput', 0, 220, { left: 'ArrowLeft', right: 'ArrowRight', up: '', down: '' }), node('p-speed', 'Variables', 0, 360, {}, [port('speed', 'number')]), node('p-mulx', 'Multiply', 220, 300), node('p-vy', 'Variables', 0, 440, {}, [port('vy', 'number')]), node('p-grav', 'Variables', 0, 520, {}, [port('gravity', 'number')]), node('p-mulg', 'Multiply', 220, 500), node('p-addvy', 'Add', 420, 460), node('p-setvy', 'Variables', 620, 0, {}, [written('vy', 'number')]), node('p-vy2', 'Variables', 620, 340, {}, [port('vy', 'number')]), node('p-move', 'Move', 840, 0, { collide: true }), node('p-br', 'Branch', 1080, 0), node('p-seq', 'Sequence', 1260, 0), node('p-vy3', 'Variables', 1080, 260, {}, [port('vy', 'number')]), node('p-cmp', 'Compare', 1260, 260, { op: '>=', b: 0 }), node('p-ground', 'Variables', 1460, 0, {}, [written('onGround', 'boolean')]), node('p-zero', 'Variables', 1460, 140, { vy: 0 }, [written('vy', 'number')]), node('p-air', 'Variables', 1260, 400, { onGround: false }, [written('onGround', 'boolean')]), node('p-key', 'OnKeyDown', 0, 700, { key: ' ' }), node('p-og', 'Variables', 0, 820, {}, [port('onGround', 'boolean')]), node('p-brj', 'Branch', 240, 700), node('p-jump', 'Variables', 240, 880, {}, [port('jumpSpeed', 'number')]), node('p-neg', 'Multiply', 440, 880, { b: -1 }), node('p-setjump', 'Variables', 660, 700, {}, [written('vy', 'number')]), node('p-col', 'OnCollisionEnter', 0, 1060), node('p-iscoin', 'IsAsset', 260, 1180, { asset: 'coin-sprite' }), node('p-brc', 'Branch', 480, 1060), node('p-seqc', 'Sequence', 680, 1060), node('p-game', 'ObjectByName', 680, 1280, { name: 'Game' }), node('p-coins', 'Properties', 1080, 1060, propertiesOf('behavior', 'game'), [written('coins', 'number')]), node('p-add1', 'Add', 1080, 1280, { b: 1 }), node('p-coinroot', 'Parent', 1080, 1420), node('p-destroy', 'Destroy', 1280, 1200), ], edges: [ edge('pe1', 'p-upd', 'exec', 'p-setvy', 'exec'), edge('pe2', 'p-vy', 'vy', 'p-addvy', 'a'), edge('pe3', 'p-grav', 'gravity', 'p-mulg', 'a'), edge('pe4', 'p-upd', 'dt', 'p-mulg', 'b'), edge('pe5', 'p-mulg', 'result', 'p-addvy', 'b'), edge('pe6', 'p-addvy', 'result', 'p-setvy', 'vy'), edge('pe7', 'p-setvy', 'exec', 'p-move', 'exec'), edge('pe8', 'p-owner', 'object', 'p-move', 'object'), edge('pe9', 'p-axis', 'x', 'p-mulx', 'a'), edge('pe10', 'p-speed', 'speed', 'p-mulx', 'b'), edge('pe11', 'p-mulx', 'result', 'p-move', 'dx'), edge('pe12', 'p-vy2', 'vy', 'p-move', 'dy'), edge('pe13', 'p-upd', 'dt', 'p-move', 'dt'), edge('pe14', 'p-move', 'exec', 'p-br', 'exec'), edge('pe15', 'p-move', 'blockedY', 'p-br', 'condition'), edge('pe16', 'p-br', 'then', 'p-seq', 'exec'), edge('pe17', 'p-seq', 'a', 'p-ground', 'exec'), edge('pe18', 'p-vy3', 'vy', 'p-cmp', 'a'), edge('pe19', 'p-cmp', 'result', 'p-ground', 'onGround'), edge('pe20', 'p-seq', 'b', 'p-zero', 'exec'), edge('pe21', 'p-br', 'else', 'p-air', 'exec'), edge('pe22', 'p-key', 'exec', 'p-brj', 'exec'), edge('pe23', 'p-og', 'onGround', 'p-brj', 'condition'), edge('pe24', 'p-brj', 'then', 'p-setjump', 'exec'), edge('pe25', 'p-jump', 'jumpSpeed', 'p-neg', 'a'), edge('pe26', 'p-neg', 'result', 'p-setjump', 'vy'), edge('pe27', 'p-col', 'exec', 'p-brc', 'exec'), edge('pe28', 'p-col', 'other', 'p-iscoin', 'object'), edge('pe29', 'p-iscoin', 'result', 'p-brc', 'condition'), edge('pe30', 'p-brc', 'then', 'p-seqc', 'exec'), edge('pe31', 'p-seqc', 'a', 'p-coins', 'exec'), edge('pe32', 'p-game', 'object', 'p-coins', 'object'), edge('pe33', 'p-coins', 'coins', 'p-add1', 'a'), edge('pe34', 'p-add1', 'result', 'p-coins', 'coins'), edge('pe36', 'p-seqc', 'b', 'p-destroy', 'exec'), edge('pe37', 'p-col', 'other', 'p-coinroot', 'object'), edge('pe38', 'p-coinroot', 'parent', 'p-destroy', 'object'), ], }; /** Switches a screen (a container) on or off. */ const screen = (id, x, y, enabled) => node(id, 'Properties', x, y, { ...propertiesOf('container'), enabled }, [written('enabled', 'boolean')]); /** * "Game": owns the run. On start it shows the title and hides everything else; while `running` * it counts the clock down, writes both HUD labels and decides win (coins reached) or loss (time * gone) by switching the screens. */ const gameGraph = { nodes: [ node('g-start', 'OnStart', 0, 0), node('g-title', 'ObjectByName', 0, 140, { name: 'Title' }), node('g-level', 'ObjectByName', 0, 220, { name: 'Level' }), node('g-win', 'ObjectByName', 0, 300, { name: 'Win' }), node('g-lose', 'ObjectByName', 0, 380, { name: 'Lose' }), screen('g-on1', 260, 0, true), screen('g-off1', 480, 0, false), screen('g-off2', 700, 0, false), screen('g-off3', 920, 0, false), node('g-upd', 'OnUpdate', 0, 520), node('g-running', 'Variables', 0, 640, {}, [port('running', 'boolean')]), node('g-brrun', 'Branch', 220, 520), node('g-time', 'Variables', 220, 700, {}, [port('timeLeft', 'number')]), node('g-sub', 'Subtract', 420, 700), node('g-max', 'Max', 600, 700, { b: 0 }), node('g-settime', 'Variables', 440, 520, {}, [written('timeLeft', 'number')]), node('g-timelabel', 'ObjectByName', 440, 860, { name: 'TimeLabel' }), node('g-time2', 'Variables', 440, 940, {}, [port('timeLeft', 'number')]), node('g-ceil', 'Ceil', 640, 940), node('g-join', 'Join', 840, 940, { a: 'Time ' }), node('g-settext', 'Properties', 700, 520, propertiesOf('text'), [written('text', 'text')]), node('g-coinlabel', 'ObjectByName', 700, 1080, { name: 'CoinLabel' }), node('g-coins', 'Variables', 700, 1160, {}, [port('coins', 'number')]), node('g-totext2', 'ToText', 900, 1160, { decimals: 0 }), node('g-join2', 'Join', 1080, 1160, { a: 'Coins ' }), node('g-join3', 'Join', 1260, 1160, { b: '/10' }), node('g-settext2', 'Properties', 960, 520, propertiesOf('text'), [written('text', 'text')]), node('g-needed', 'Variables', 960, 700, {}, [port('needed', 'number')]), node('g-cmpwin', 'Compare', 1160, 700, { op: '>=' }), node('g-brwin', 'Branch', 1220, 520), node('g-stop1', 'Variables', 1420, 440, { running: false }, [written('running', 'boolean')]), screen('g-leveloff1', 1620, 440, false), screen('g-winon', 1820, 440, true), node('g-cmplose', 'Compare', 1160, 860, { op: '<=', b: 0 }), node('g-brlose', 'Branch', 1420, 640), node('g-stop2', 'Variables', 1620, 640, { running: false }, [written('running', 'boolean')]), screen('g-leveloff2', 1820, 640, false), screen('g-loseon', 2020, 640, true), ], edges: [ edge('ge1', 'g-start', 'exec', 'g-on1', 'exec'), edge('ge2', 'g-title', 'object', 'g-on1', 'object'), edge('ge3', 'g-on1', 'exec', 'g-off1', 'exec'), edge('ge4', 'g-level', 'object', 'g-off1', 'object'), edge('ge5', 'g-off1', 'exec', 'g-off2', 'exec'), edge('ge6', 'g-win', 'object', 'g-off2', 'object'), edge('ge7', 'g-off2', 'exec', 'g-off3', 'exec'), edge('ge8', 'g-lose', 'object', 'g-off3', 'object'), edge('ge9', 'g-upd', 'exec', 'g-brrun', 'exec'), edge('ge10', 'g-running', 'running', 'g-brrun', 'condition'), edge('ge11', 'g-brrun', 'then', 'g-settime', 'exec'), edge('ge12', 'g-time', 'timeLeft', 'g-sub', 'a'), edge('ge13', 'g-upd', 'dt', 'g-sub', 'b'), edge('ge14', 'g-sub', 'result', 'g-max', 'a'), edge('ge15', 'g-max', 'result', 'g-settime', 'timeLeft'), edge('ge16', 'g-settime', 'exec', 'g-settext', 'exec'), edge('ge17', 'g-timelabel', 'object', 'g-settext', 'object'), edge('ge18', 'g-time2', 'timeLeft', 'g-ceil', 'value'), edge('ge19', 'g-ceil', 'result', 'g-join', 'b'), edge('ge20', 'g-join', 'text', 'g-settext', 'text'), edge('ge21', 'g-settext', 'exec', 'g-settext2', 'exec'), edge('ge22', 'g-coinlabel', 'object', 'g-settext2', 'object'), edge('ge23', 'g-coins', 'coins', 'g-totext2', 'value'), edge('ge24', 'g-totext2', 'text', 'g-join2', 'b'), edge('ge25', 'g-join2', 'text', 'g-join3', 'a'), edge('ge26', 'g-join3', 'text', 'g-settext2', 'text'), edge('ge27', 'g-settext2', 'exec', 'g-brwin', 'exec'), edge('ge28', 'g-coins', 'coins', 'g-cmpwin', 'a'), edge('ge29', 'g-needed', 'needed', 'g-cmpwin', 'b'), edge('ge30', 'g-cmpwin', 'result', 'g-brwin', 'condition'), edge('ge31', 'g-brwin', 'then', 'g-stop1', 'exec'), edge('ge32', 'g-stop1', 'exec', 'g-leveloff1', 'exec'), edge('ge33', 'g-level', 'object', 'g-leveloff1', 'object'), edge('ge34', 'g-leveloff1', 'exec', 'g-winon', 'exec'), edge('ge35', 'g-win', 'object', 'g-winon', 'object'), edge('ge36', 'g-brwin', 'else', 'g-brlose', 'exec'), edge('ge37', 'g-time2', 'timeLeft', 'g-cmplose', 'a'), edge('ge38', 'g-cmplose', 'result', 'g-brlose', 'condition'), edge('ge39', 'g-brlose', 'then', 'g-stop2', 'exec'), edge('ge40', 'g-stop2', 'exec', 'g-leveloff2', 'exec'), edge('ge41', 'g-level', 'object', 'g-leveloff2', 'object'), edge('ge42', 'g-leveloff2', 'exec', 'g-loseon', 'exec'), edge('ge43', 'g-lose', 'object', 'g-loseon', 'object'), ], }; /** "Start button": a click resets the counters on the Game behavior and swaps title for level. */ const startButtonGraph = { nodes: [ node('s-click', 'OnClick', 0, 0), node('s-game', 'ObjectByName', 0, 160, { name: 'Game' }), node('s-title', 'ObjectByName', 0, 240, { name: 'Title' }), node('s-level', 'ObjectByName', 0, 320, { name: 'Level' }), node( 's-reset', 'Properties', 240, 0, { ...propertiesOf('behavior', 'game'), coins: 0, timeLeft: 30, running: true }, [written('coins', 'number'), written('timeLeft', 'number'), written('running', 'boolean')], ), screen('s-hide', 900, 0, false), screen('s-show', 1120, 0, true), ], edges: [ edge('se1', 's-click', 'exec', 's-reset', 'exec'), edge('se2', 's-game', 'object', 's-reset', 'object'), edge('se7', 's-reset', 'exec', 's-hide', 'exec'), edge('se8', 's-title', 'object', 's-hide', 'object'), edge('se9', 's-hide', 'exec', 's-show', 'exec'), edge('se10', 's-level', 'object', 's-show', 'object'), ], }; /** "Restart button": one click, one node – the scene starts over from its saved state. */ const restartButtonGraph = { nodes: [node('r-click', 'OnClick', 0, 0), node('r-restart', 'RestartGame', 260, 0)], edges: [edge('re1', 'r-click', 'exec', 'r-restart', 'exec')], }; const project = { formatVersion: 2, id: projectId, name: 'Jump and Run', activeSceneId: 'scene1', assets: [ { id: 'scene1', type: 'scene', name: 'Level 1', props: { backgroundColor: SKY }, objects: [ sceneObject('L', 'level', { name: 'Level' }), // All three screens sit on the camera frame; the Game behavior decides on `On start` which // one is up. Editor offsets pull them apart in the scene view so they do not cover the level. sceneObject('T', 'title', { name: 'Title', z: 10, overrides: { enabled: true }, editorOffset: { x: -750, y: -500 }, }), sceneObject('W', 'win', { name: 'Win', z: 10, overrides: { enabled: true }, editorOffset: { x: 0, y: -500 }, }), sceneObject('B', 'lose', { name: 'Lose', z: 10, overrides: { enabled: true }, editorOffset: { x: 750, y: -500 }, }), sceneObject('ocam', 'cam', { name: 'Main Camera' }), sceneObject('ogame', 'game', { name: 'Game', x: -200, y: -220 }), ], }, { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, { id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } }, { id: 'player-sprite', type: 'sprite', name: 'Player Sprite', props: { width: 30, height: 40, color: PLAYER }, }, { id: 'coin-sprite', type: 'sprite', name: 'Coin Sprite', props: { width: 24, height: 24, color: COIN } }, { id: 'label', type: 'text', name: 'Label', props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' }, }, { id: 'solid', type: 'collider', name: 'Solid Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false }, }, { id: 'trigger', type: 'collider', name: 'Coin Trigger', props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true }, }, { id: 'player', type: 'behavior', name: 'Player', props: { speed: 170, jumpSpeed: 480, gravity: 1200, vy: 0, onGround: false }, variables: [ variable('speed', 'number', 0), variable('jumpSpeed', 'number', 0), variable('gravity', 'number', 0), variable('vy', 'number', 0), variable('onGround', 'boolean', false), ], graph: playerGraph, }, { id: 'game', type: 'behavior', name: 'Game', props: { timeLeft: 30, coins: 0, needed: 10, running: false }, variables: [ variable('timeLeft', 'number', 30), variable('coins', 'number', 0), variable('needed', 'number', 10), variable('running', 'boolean', false), ], graph: gameGraph, }, { id: 'start-button', type: 'behavior', name: 'Start Button', props: {}, variables: [], graph: startButtonGraph, }, { id: 'restart-button', type: 'behavior', name: 'Restart Button', props: {}, variables: [], graph: restartButtonGraph, }, { id: 'coin', type: 'container', name: 'Coin', props: {}, objects: templates.coin }, { id: 'level', type: 'container', name: 'Level', props: {}, objects: templates.level }, { id: 'title', type: 'container', name: 'Title screen', props: {}, objects: templates.title }, { id: 'win', type: 'container', name: 'Win screen', props: {}, objects: templates.win }, { id: 'lose', type: 'container', name: 'Lose screen', props: {}, objects: templates.lose }, ], }; await writeProject(projectId, baseUrl, project);