#!/usr/bin/env node /** * Writes the four-direction walk demo: one character sheet, one sprite, one behavior – nothing else. * * The sheet (Actor1.png, 576×384) is the usual RPG-Maker layout: a 12×8 grid of 48×48 cells holding * eight characters, each of them three columns (left foot / standing / right foot) by four rows * (down, left, right, up). This demo uses the character in the top left corner, so its frames are * the columns 1-3 of the rows 1-4. * * Walking is one animation per direction, playing those three frames as `pingpong` * (left-stand-right-stand …) – the original walk cycle. Standing is not an animation at all but the * middle frame of the row. * * The graph picks a picture the way the editor does: two list variables hold the four walk * animations and the four standing frames, each item chosen with the texture picker, and `facing` * is the row index that indexes both. Nothing is built out of an id spelled by hand. * * Usage: node tools/seed-actor.mjs [projectId] [baseUrl] */ import { fileURLToPath } from 'node:url'; import { edge, node, port, propertiesOf, sceneObject, tagged, variable, written } from './lib/builders.mjs'; import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs'; import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor); const SHEET_FILE = fileURLToPath(new URL('./assets/Actor1.png', import.meta.url)); const GRASS = 0x2f3d33; const CELL = 48; const COLUMNS = 12; const ROWS = 8; // The camera shows x −320…320, y −180…180; +y is down. Half a sprite of margin keeps the whole // character inside the frame rather than half of it. const MAX_X = 320 - CELL / 2; const MAX_Y = 180 - CELL / 2; const SPEED = 150; /** The rows of a character, in sheet order. The row index is the direction. */ const DIRECTIONS = ['down', 'left', 'right', 'up']; const WALK = (dir) => `walk-${dir}`; /** The id of a grid cell. Plain: no part of the project builds one of these out of a name. */ const frameId = (row, column) => `fr-${row}-${column}`; /** The standing frame of a direction: the middle column of its row. */ const idleFrame = (row) => frameId(row, 1); /** Every cell of the 12×8 grid, in reading order and named like the slice dialog names them. */ const frames = []; for (let row = 0; row < ROWS; row++) for (let column = 0; column < COLUMNS; column++) frames.push({ id: frameId(row, column), name: `Frame ${row * COLUMNS + column + 1}`, x: column * CELL, y: row * CELL, w: CELL, h: CELL, }); const walkAnimations = DIRECTIONS.map((dir, row) => ({ id: WALK(dir), type: 'animation', name: `Walk ${dir}`, props: { fps: 8, playback: 'pingpong' }, frames: [frameId(row, 0), frameId(row, 1), frameId(row, 2)], })); /** The two lists the graph indexes: one walk animation and one standing frame per direction. */ const WALK_PICTURES = DIRECTIONS.map((dir) => tagged('texture', WALK(dir))); const IDLE_PICTURES = DIRECTIONS.map((_, row) => tagged('texture', idleFrame(row))); /** * Sets `facing` to a constant – one per direction, so no edge has to cross the graph. It is the row * of the sheet, which is also the index into both picture lists. */ const face = (id, dir, x, y) => node(id, 'Variables', x, y, { facing: DIRECTIONS.indexOf(dir) }, [written('facing', 'number')]); const nodes = [ // Which way the character looks: the key pressed last wins. Holding W and tapping D turns him // right while he keeps walking up and right. node('k-up', 'OnKeyDown', 0, -560, { key: 'w' }), node('k-left', 'OnKeyDown', 0, -440, { key: 'a' }), node('k-down', 'OnKeyDown', 0, -320, { key: 's' }), node('k-right', 'OnKeyDown', 0, -200, { key: 'd' }), face('a-up', 'up', 300, -560), face('a-left', 'left', 300, -440), face('a-down', 'down', 300, -320), face('a-right', 'right', 300, -200), // Walking. `Axis input` already normalizes, so a diagonal is not faster than a straight line. node('upd', 'OnUpdate', 0, 0), node('owner', 'Owner', 0, 160), node('axis', 'AxisInput', 0, 300, { left: 'a', right: 'd', up: 'w', down: 's' }), node('v-speed', 'Variables', 0, 580, {}, [port('speed', 'number')]), node('step', 'Multiply', 300, 580), node('mx', 'Multiply', 540, 360), node('my', 'Multiply', 540, 500), node('pos', 'GetPosition', 300, 140), node('sum-x', 'Add', 780, 180), node('sum-y', 'Add', 780, 340), node('clamp-x', 'Clamp', 1000, 180, { min: -MAX_X, max: MAX_X }), node('clamp-y', 'Clamp', 1000, 360, { min: -MAX_Y, max: MAX_Y }), node('setpos', 'SetPosition', 1240, 0), node('seq', 'Sequence', 1460, 0), // Letting go of the key he faces has to turn him, or he would keep walking backwards. Whenever // exactly one axis is held, that axis alone decides – only a diagonal leaves the last key standing. node('x-on', 'Compare', 540, 720, { op: '!=', b: 0 }), node('y-on', 'Compare', 540, 860, { op: '!=', b: 0 }), node('no-y', 'Not', 780, 720), node('no-x', 'Not', 780, 980), node('only-x', 'And', 1000, 720), node('only-y', 'And', 1000, 900), node('x-neg', 'Compare', 1240, 720, { op: '<', b: 0 }), node('y-neg', 'Compare', 1240, 860, { op: '<', b: 0 }), node('br-x', 'Branch', 1460, 660), node('br-x-dir', 'Branch', 1680, 660), face('c-left', 'left', 1920, 620), face('c-right', 'right', 1920, 760), node('br-y', 'Branch', 1460, 980), node('br-y-dir', 'Branch', 1680, 980), face('c-up', 'up', 1920, 940), face('c-down', 'down', 1920, 1080), // What the sprite shows. Both picture lists are indexed by the row the character faces, so there // is one node per list and no branch per direction. // Setting the same animation again lets it keep running, so writing it every frame is free. node('br-move', 'Branch', 1460, 1280), node('v-pics', 'Variables', 1400, 1480, {}, [ port('walk', 'list'), port('idle', 'list'), port('facing', 'number'), ]), node('pick-walk', 'ItemAt', 1680, 1430), node('pick-idle', 'ItemAt', 1680, 1640), node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'texture')]), node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'texture')]), ]; const edges = [ edge('e1', 'k-up', 'exec', 'a-up', 'exec'), edge('e2', 'k-left', 'exec', 'a-left', 'exec'), edge('e3', 'k-down', 'exec', 'a-down', 'exec'), edge('e4', 'k-right', 'exec', 'a-right', 'exec'), edge('e5', 'v-speed', 'speed', 'step', 'a'), edge('e6', 'upd', 'dt', 'step', 'b'), edge('e7', 'axis', 'x', 'mx', 'a'), edge('e8', 'step', 'result', 'mx', 'b'), edge('e9', 'axis', 'y', 'my', 'a'), edge('e10', 'step', 'result', 'my', 'b'), edge('e11', 'owner', 'object', 'pos', 'object'), edge('e12', 'pos', 'x', 'sum-x', 'a'), edge('e13', 'mx', 'result', 'sum-x', 'b'), edge('e14', 'pos', 'y', 'sum-y', 'a'), edge('e15', 'my', 'result', 'sum-y', 'b'), edge('e16', 'sum-x', 'result', 'clamp-x', 'value'), edge('e17', 'sum-y', 'result', 'clamp-y', 'value'), edge('e18', 'upd', 'exec', 'setpos', 'exec'), edge('e19', 'owner', 'object', 'setpos', 'object'), edge('e20', 'clamp-x', 'result', 'setpos', 'x'), edge('e21', 'clamp-y', 'result', 'setpos', 'y'), edge('e22', 'setpos', 'exec', 'seq', 'exec'), edge('e23', 'axis', 'x', 'x-on', 'a'), edge('e24', 'axis', 'y', 'y-on', 'a'), edge('e25', 'y-on', 'result', 'no-y', 'value'), edge('e26', 'x-on', 'result', 'no-x', 'value'), edge('e27', 'x-on', 'result', 'only-x', 'a'), edge('e28', 'no-y', 'result', 'only-x', 'b'), edge('e29', 'y-on', 'result', 'only-y', 'a'), edge('e30', 'no-x', 'result', 'only-y', 'b'), edge('e31', 'axis', 'x', 'x-neg', 'a'), edge('e32', 'axis', 'y', 'y-neg', 'a'), edge('e33', 'seq', 'a', 'br-x', 'exec'), edge('e34', 'only-x', 'result', 'br-x', 'condition'), edge('e35', 'br-x', 'then', 'br-x-dir', 'exec'), edge('e36', 'x-neg', 'result', 'br-x-dir', 'condition'), edge('e37', 'br-x-dir', 'then', 'c-left', 'exec'), edge('e38', 'br-x-dir', 'else', 'c-right', 'exec'), edge('e39', 'seq', 'b', 'br-y', 'exec'), edge('e40', 'only-y', 'result', 'br-y', 'condition'), edge('e41', 'br-y', 'then', 'br-y-dir', 'exec'), edge('e42', 'y-neg', 'result', 'br-y-dir', 'condition'), edge('e43', 'br-y-dir', 'then', 'c-up', 'exec'), edge('e44', 'br-y-dir', 'else', 'c-down', 'exec'), edge('e45', 'seq', 'c', 'br-move', 'exec'), edge('e46', 'axis', 'active', 'br-move', 'condition'), edge('e47', 'v-pics', 'walk', 'pick-walk', 'list'), edge('e48', 'v-pics', 'facing', 'pick-walk', 'index'), edge('e49', 'v-pics', 'idle', 'pick-idle', 'list'), edge('e50', 'v-pics', 'facing', 'pick-idle', 'index'), edge('e51', 'br-move', 'then', 'wear-walk', 'exec'), edge('e52', 'owner', 'object', 'wear-walk', 'object'), edge('e53', 'pick-walk', 'item', 'wear-walk', 'texture'), edge('e54', 'br-move', 'else', 'wear-idle', 'exec'), edge('e55', 'owner', 'object', 'wear-idle', 'object'), edge('e56', 'pick-idle', 'item', 'wear-idle', 'texture'), ]; const sheet = await uploadTexture(baseUrl, SHEET_FILE); const project = { formatVersion: 2, id: projectId, name: 'Actor demo', activeSceneId: 'scene1', assets: [ { id: 'scene1', type: 'scene', name: 'Scene', props: { backgroundColor: GRASS }, objects: [ sceneObject('oplayer', 'actor-sprite', { name: 'Player' }), sceneObject('oplayer-logic', 'player', { parentId: 'oplayer', y: -90 }), sceneObject('ocam', 'cam', { name: 'Main Camera' }), ], }, { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, { id: 'sheet', type: 'texture', name: 'Actor1', // Pixel art: scaling it smoothly would blur the 48×48 cells into each other. props: { filter: 'nearest' }, file: sheet.file, width: sheet.width, height: sheet.height, frames, }, ...walkAnimations, // Present but worn by nothing: the smoke reads the actor's pixels, a filter pass must not touch them. adjustColorsShader(), { id: 'actor-sprite', type: 'sprite', name: 'Actor', // White, or the tint would colour the frames. The texture is the standing frame it starts on. props: { width: CELL, height: CELL, color: 0xffffff, texture: idleFrame(0) }, }, { id: 'player', type: 'behavior', name: 'Player', props: { speed: SPEED, facing: 0, walk: WALK_PICTURES, idle: IDLE_PICTURES }, variables: [ variable('speed', 'number', SPEED), variable('facing', 'number', 0), variable('walk', 'list', WALK_PICTURES), variable('idle', 'list', IDLE_PICTURES), ], graph: { nodes, edges }, }, ], }; await writeProject(projectId, baseUrl, project);