yuuna-engine 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,135 +1,194 @@
1
- <p align="center">
2
- <img src="dist/resources/yuuna.png" alt="Yuuna" width="120" height="120" />
3
- </p>
4
-
5
- # Yuuna
6
-
7
- A lightweight, state-machine-based TypeScript game engine built for quick
8
- prototypes — drop it into a page and it's running, no editor or build step
9
- required. You describe your game as a `state`, a `render(state)` function,
10
- and a `nextState({ state, event, keyboard })` function Yuuna owns the
11
- render loop, input handling, and canvas drawing. It's scratch paper for game
12
- ideas, not a replacement for Godot or Unity.
13
-
14
- ## Install
15
-
16
- ```sh
17
- npm install yuuna-engine
18
- ```
19
-
20
- ## Quick start
21
-
22
- Add a canvas with `id="yuuna"` to your page:
23
-
24
- ```html
25
- <canvas id="yuuna"></canvas>
26
- ```
27
-
28
- Then describe your game as state + render + nextState:
29
-
30
- ```ts
31
- import { runEngine } from "yuuna-engine";
32
-
33
- type GameState = { cookies: number };
34
-
35
- runEngine<GameState>({
36
- initialState: { cookies: 0 },
37
-
38
- // Optional size and color the canvas from code instead of HTML/CSS
39
- canvas: { width: 960, height: 540, backgroundColor: "#0d1831" },
40
-
41
- render: (state) => ({
42
- renderables: [
43
- {
44
- type: "TEXT",
45
- text: `${state.cookies} cookies`,
46
- color: "black",
47
- position: { x: 100, y: 50 },
48
- },
49
- {
50
- type: "CIRCLE",
51
- id: "cookie",
52
- isClickable: true,
53
- color: "brown",
54
- position: { x: 50, y: 50 },
55
- radius: 25,
56
- },
57
- ],
58
- }),
59
-
60
- nextState: ({ state, event }) => {
61
- if (event.tag === "CLICK" && event.id === "cookie") {
62
- return { cookies: state.cookies + 1 };
63
- }
64
-
65
- return state;
66
- },
67
- });
68
- ```
69
-
70
- ## Concepts
71
-
72
- - **Renderables** — declarative shapes drawn each frame: `RECTANGLE`,
73
- `CIRCLE`, `TEXT`, `SPRITE`, and `LINE`. Give one an `id` plus
74
- `isClickable` / `isHoverable` / `trackMouseMovement` to make it
75
- interactive.
76
- - **Events** your `nextState` function receives one `GameEvent` per call:
77
- `TIME` (frame tick with `delta`), `CLICK`, `HOVER_IN`, `HOVER_OUT`, or
78
- `MOUSE_MOVE`.
79
- - **Keyboard** `nextState` also receives a `keyboard` map keyed by
80
- `KeyCode`-style keys (e.g. `"KeyW"`, `"ArrowLeft"`, `"Space"`), each with
81
- `isPressed` / `isJustPressed` / `isJustReleased`.
82
- - **Sprites** pass a `resources` map of `{ src, size, slices }` to
83
- `runEngine` to load spritesheets, then reference them by id with a
84
- `SPRITE` renderable's `resourceId` and `frame`. Set `flipX: true` to
85
- mirror a sprite horizontally useful when the art is drawn facing one
86
- direction but needs to move the other way.
87
- - **Canvas** pass `canvas: { width, height, backgroundColor }` to
88
- `runEngine` to size and color the canvas from code. All three are
89
- optional; anything you don't set falls back to the canvas element's
90
- existing HTML/CSS.
91
- - **Mechanics**`nextState` can also be an array of small
92
- `NextStateFunction`s instead of one big function. Each one is run in
93
- order for every event, and can return:
94
- - a new state, to update to
95
- - `undefined` (or no `return` at all) no change, but the rest of the
96
- list still runs, so a guard can just be `if (...) return;`
97
- - `STOP` (imported from `yuuna-engine`)no change, and the rest of
98
- the list is skipped for this event, so a shared rule (like "nothing
99
- happens once the game is over") only needs to be written once
100
-
101
- ```ts
102
- import { runEngine, STOP, type NextStateFunction } from "yuuna-engine";
103
-
104
- const freezeOnGameOver: NextStateFunction<GameState> = ({ state }) => {
105
- if (state.lives <= 0) return STOP;
106
- };
107
-
108
- const moveEnemies: NextStateFunction<GameState> = ({ state, event }) => {
109
- if (event.tag === "TIME") {
110
- return { ...state, enemies: move(state.enemies, event.delta) };
111
- }
112
- };
113
-
114
- runEngine<GameState>({
115
- initialState,
116
- render,
117
- nextState: [freezeOnGameOver, moveEnemies /* ... */],
118
- });
119
- ```
120
-
121
- ## Development
122
-
123
- ```sh
124
- yarn install
125
- yarn build # builds lib/ (npm package) and dist/bundle.js (landing page)
126
- yarn watch # rebuild on change
127
- ```
128
-
129
- `dist/index.html` is the landing page it loads `dist/bundle.js` in the
130
- browser via a global `Yuuna` object and embeds a live Monaco editor so
131
- visitors can edit and run a game directly on the page.
132
-
133
- ## License
134
-
135
- MIT © [lucy-dot-exe](https://github.com/lucy-dot-exe)
1
+ <p align="center">
2
+ <img src="dist/resources/yuuna.png" alt="Yuuna" width="120" height="120" />
3
+ </p>
4
+
5
+ # Yuuna
6
+
7
+ [Live demo & playground](https://lucy-dot-exe.github.io/yuuna/) · [GitHub](https://github.com/lucy-dot-exe/yuuna)
8
+
9
+ A lightweight, state-machine-based TypeScript game engine built for quick
10
+ prototypes — drop it into a page and it's running, no editor or build step
11
+ required. You describe your game as a `state`, a `render(state)` function,
12
+ and a `nextState({ state, event, keyboard })` function Yuuna owns the
13
+ render loop, input handling, and canvas drawing. It's scratch paper for game
14
+ ideas, not a replacement for Godot or Unity.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install yuuna-engine
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ Add a canvas with `id="yuuna"` to your page:
25
+
26
+ ```html
27
+ <canvas id="yuuna"></canvas>
28
+ ```
29
+
30
+ Then describe your game as state + render + nextState:
31
+
32
+ ```ts
33
+ import { runEngine } from "yuuna-engine";
34
+
35
+ type GameState = { cookies: number };
36
+
37
+ runEngine<GameState>({
38
+ initialState: { cookies: 0 },
39
+
40
+ // Optional — size and color the canvas from code instead of HTML/CSS
41
+ canvas: { width: 960, height: 540, backgroundColor: "#0d1831" },
42
+
43
+ render: (state) => ({
44
+ renderables: [
45
+ {
46
+ type: "TEXT",
47
+ text: `${state.cookies} cookies`,
48
+ color: "black",
49
+ position: { x: 100, y: 50 },
50
+ },
51
+ {
52
+ type: "CIRCLE",
53
+ id: "cookie",
54
+ isClickable: true,
55
+ color: "brown",
56
+ position: { x: 50, y: 50 },
57
+ radius: 25,
58
+ },
59
+ ],
60
+ }),
61
+
62
+ nextState: ({ state, event }) => {
63
+ if (event.tag === "CLICK" && event.id === "cookie") {
64
+ return { cookies: state.cookies + 1 };
65
+ }
66
+
67
+ return state;
68
+ },
69
+ });
70
+ ```
71
+
72
+ ## Concepts
73
+
74
+ - **Renderables** declarative shapes drawn each frame: `RECTANGLE`,
75
+ `CIRCLE`, `TEXT`, `SPRITE`, `LINE`, `GROUP`, and `ANIMATED_SPRITE`. Give
76
+ one an `id` plus `isClickable` / `isHoverable` / `trackMouseMovement` to
77
+ make it interactive. `TEXT` also takes a `fontSize` (defaults to `30`).
78
+ `GROUP` draws nothing itself — it's just a `position` (plus the usual
79
+ `scale`/`modulate`/`layer` below) for `children` to hang off of, for
80
+ grouping renderables that should move/scale/tint together without
81
+ needing a shape of its own; it's never interactable, since it has no
82
+ shape to hit-test. `ANIMATED_SPRITE` is `SPRITE` with an `animation`
83
+ (name of one defined on that resource see Sprites below) and an
84
+ optional `timeScale` instead of a `frame` number; the engine picks the
85
+ frame for you based on how long it's been playing. Unlike every other
86
+ renderable, its `id` is required — that's how the engine recognizes
87
+ "this is the same sprite as last frame" across renders (and switches to
88
+ frame 0 if `animation` changes for that `id`), so reusing an id across
89
+ two different entities will make their animations bleed together.
90
+ Every renderable also takes:
91
+ - `layer`higher values render later, i.e. in front of lower ones.
92
+ Defaults to `0`; renderables on the same layer keep `render()`'s order.
93
+ - `scale` `{ x, y }` multiplier on the renderable's size, anchored at
94
+ its `position` (or `from`, for `LINE`). Defaults to `{ x: 1, y: 1 }`.
95
+ A `CIRCLE` scaled unevenly draws (and hit-tests) as an ellipse.
96
+ - `modulate` a CSS color string that multiplies the renderable's color
97
+ channel-by-channel, the same way Godot's `modulate` works e.g.
98
+ `"#808080"` halves brightness, `"#ff0000"` keeps only the red channel.
99
+ - `children` nested renderables, positioned relative to this one, like
100
+ Godot's parent/child nodes. A child's `position` is added to its
101
+ parent's (scaled by the parent's own `scale`), and `scale` / `modulate`
102
+ / `layer` all compose down the tree a child's effective scale is the
103
+ parent's times its own, `modulate` multiplies the same way, and
104
+ `layer` adds (relative to the parent's, matching Godot's default: a
105
+ deeply-nested child can still end up drawn in front of an unrelated
106
+ top-level renderable if its accumulated layer says so). A child is a
107
+ full `Renderable`, so it can have its own `id` / `isClickable` / even
108
+ its own `children`. `screenSpace: true` makes a renderable (and its
109
+ whole subtree) ignore the camera below and stay fixed to the screen —
110
+ for UI/HUD that shouldn't pan or zoom with the game world.
111
+ - **Camera** — pass `camera: (state) => ({ x, y, zoom })` to `runEngine` to
112
+ pan/zoom every world-space renderable (anything without
113
+ `screenSpace: true`) as a group, the same way a `GROUP` parent works for
114
+ its children — `{ x, y }` is the world position mapped to canvas
115
+ `(0, 0)`, and `zoom` scales everything around that same point. It's a
116
+ function of state, so the camera can follow something or react to a
117
+ zoom level you're tracking yourself.
118
+ - **Events** — your `nextState` function receives one `GameEvent` per call:
119
+ `TIME` (frame tick with `delta`), `CLICK`, `HOVER_IN`, `HOVER_OUT`, or
120
+ `MOUSE_MOVE`. The mouse-carrying ones include both `mouse` (raw canvas
121
+ pixels — use for `screenSpace`/UI logic) and `worldMouse` (that same
122
+ position run through the camera's inverse transform — use to
123
+ place/locate world-space things, e.g. build a turret where the player
124
+ clicked). With no `camera` set, `worldMouse` always equals `mouse`.
125
+ - **Keyboard** `nextState` also receives a `keyboard` map keyed by
126
+ `KeyCode`-style keys (e.g. `"KeyW"`, `"ArrowLeft"`, `"Space"`), each with
127
+ `isPressed` / `isJustPressed` / `isJustReleased`.
128
+ - **Sprites** — pass a `resources` map of `{ src, size, slices }` to
129
+ `runEngine` to load spritesheets, then reference them by id with a
130
+ `SPRITE` renderable's `resourceId` and `frame`. Set `flipX: true` to
131
+ mirror a sprite horizontally useful when the art is drawn facing one
132
+ direction but needs to move the other way. Add an `animations` map to a
133
+ resource — `{ frames: number[], frameDuration, loop }` each — to play
134
+ one with `ANIMATED_SPRITE` instead of managing `frame` by hand.
135
+ - **Sound effects** — pass a `sounds` map of `{ src }` to `runEngine`, then
136
+ call the `playSound(id)` function `nextState` receives to play one, e.g.
137
+ `playSound("collect")` when a cookie is clicked. Calling it again while
138
+ a sound is still playing overlaps a new copy instead of cutting the
139
+ first one off.
140
+ - **Music** — pass a `music` map of `{ src }` to `runEngine`, then use the
141
+ `playMusic(id)` / `pauseMusic()` functions `nextState` receives to
142
+ control a looping background track. Unlike `playSound`, only one track
143
+ plays at a time and it keeps running in the background across frames
144
+ instead of firing once; `pauseMusic()` leaves it where it stopped, so
145
+ calling `playMusic(id)` again resumes it instead of starting over.
146
+ - **Canvas** — pass `canvas: { width, height, backgroundColor }` to
147
+ `runEngine` to size and color the canvas from code. All three are
148
+ optional; anything you don't set falls back to the canvas element's
149
+ existing HTML/CSS.
150
+ - **Mechanics** — `nextState` can also be an array of small
151
+ `NextStateFunction`s instead of one big function. Each one is run in
152
+ order for every event, and can return:
153
+ - a new state, to update to
154
+ - `undefined` (or no `return` at all) — no change, but the rest of the
155
+ list still runs, so a guard can just be `if (...) return;`
156
+ - `STOP` (imported from `yuuna-engine`) — no change, and the rest of
157
+ the list is skipped for this event, so a shared rule (like "nothing
158
+ happens once the game is over") only needs to be written once
159
+
160
+ ```ts
161
+ import { runEngine, STOP, type NextStateFunction } from "yuuna-engine";
162
+
163
+ const freezeOnGameOver: NextStateFunction<GameState> = ({ state }) => {
164
+ if (state.lives <= 0) return STOP;
165
+ };
166
+
167
+ const moveEnemies: NextStateFunction<GameState> = ({ state, event }) => {
168
+ if (event.tag === "TIME") {
169
+ return { ...state, enemies: move(state.enemies, event.delta) };
170
+ }
171
+ };
172
+
173
+ runEngine<GameState>({
174
+ initialState,
175
+ render,
176
+ nextState: [freezeOnGameOver, moveEnemies /* ... */],
177
+ });
178
+ ```
179
+
180
+ ## Development
181
+
182
+ ```sh
183
+ yarn install
184
+ yarn build # builds lib/ (npm package) and dist/bundle.js (landing page)
185
+ yarn watch # rebuild on change
186
+ ```
187
+
188
+ `dist/index.html` is the landing page — it loads `dist/bundle.js` in the
189
+ browser via a global `Yuuna` object and embeds a live Monaco editor so
190
+ visitors can edit and run a game directly on the page.
191
+
192
+ ## License
193
+
194
+ MIT © [lucy-dot-exe](https://github.com/lucy-dot-exe)
@@ -1,4 +1,18 @@
1
- export type RectangleRenderable = {
1
+ type BaseRenderable = {
2
+ layer?: number;
3
+ scale?: {
4
+ x: number;
5
+ y: number;
6
+ };
7
+ modulate?: string;
8
+ children?: Renderable[];
9
+ screenSpace?: boolean;
10
+ id?: string;
11
+ isClickable?: boolean;
12
+ isHoverable?: boolean;
13
+ trackMouseMovement?: boolean;
14
+ };
15
+ export type RectangleRenderable = BaseRenderable & {
2
16
  type: "RECTANGLE";
3
17
  position: {
4
18
  x: number;
@@ -9,12 +23,8 @@ export type RectangleRenderable = {
9
23
  height: number;
10
24
  };
11
25
  color: string;
12
- id?: string;
13
- isClickable?: boolean;
14
- isHoverable?: boolean;
15
- trackMouseMovement?: boolean;
16
26
  };
17
- export type CircleRenderable = {
27
+ export type CircleRenderable = BaseRenderable & {
18
28
  type: "CIRCLE";
19
29
  position: {
20
30
  x: number;
@@ -22,12 +32,8 @@ export type CircleRenderable = {
22
32
  };
23
33
  radius: number;
24
34
  color: string;
25
- id?: string;
26
- isClickable?: boolean;
27
- isHoverable?: boolean;
28
- trackMouseMovement?: boolean;
29
35
  };
30
- export type TextRenderable = {
36
+ export type TextRenderable = BaseRenderable & {
31
37
  type: "TEXT";
32
38
  text: string;
33
39
  color: string;
@@ -39,12 +45,9 @@ export type TextRenderable = {
39
45
  x: "left" | "center" | "right";
40
46
  y: "bottom" | "middle" | "top";
41
47
  };
42
- id?: string;
43
- isClickable?: boolean;
44
- isHoverable?: boolean;
45
- trackMouseMovement?: boolean;
48
+ fontSize?: number;
46
49
  };
47
- export type SpriteRenderable = {
50
+ export type SpriteRenderable = BaseRenderable & {
48
51
  type: "SPRITE";
49
52
  position: {
50
53
  x: number;
@@ -52,15 +55,10 @@ export type SpriteRenderable = {
52
55
  };
53
56
  resourceId: string;
54
57
  frame: number;
55
- scale?: number;
56
58
  opacity?: number;
57
59
  flipX?: boolean;
58
- id?: string;
59
- isClickable?: boolean;
60
- isHoverable?: boolean;
61
- trackMouseMovement?: boolean;
62
60
  };
63
- export type LineRenderable = {
61
+ export type LineRenderable = BaseRenderable & {
64
62
  type: "LINE";
65
63
  from: {
66
64
  x: number;
@@ -72,12 +70,29 @@ export type LineRenderable = {
72
70
  };
73
71
  color: string;
74
72
  width?: number;
75
- id?: string;
76
- isClickable?: boolean;
77
- isHoverable?: boolean;
78
- trackMouseMovement?: boolean;
79
73
  };
80
- export type Renderable = RectangleRenderable | CircleRenderable | SpriteRenderable | TextRenderable | LineRenderable;
74
+ export type AnimatedSpriteRenderable = BaseRenderable & {
75
+ type: "ANIMATED_SPRITE";
76
+ position: {
77
+ x: number;
78
+ y: number;
79
+ };
80
+ resourceId: string;
81
+ animation: string;
82
+ timeScale?: number;
83
+ paused?: boolean;
84
+ opacity?: number;
85
+ flipX?: boolean;
86
+ id: string;
87
+ };
88
+ export type GroupRenderable = BaseRenderable & {
89
+ type: "GROUP";
90
+ position: {
91
+ x: number;
92
+ y: number;
93
+ };
94
+ };
95
+ export type Renderable = RectangleRenderable | CircleRenderable | SpriteRenderable | TextRenderable | LineRenderable | GroupRenderable | AnimatedSpriteRenderable;
81
96
  export type TimeEvent = {
82
97
  tag: "TIME";
83
98
  delta: number;
@@ -89,6 +104,10 @@ export type ClickEvent = {
89
104
  x: number;
90
105
  y: number;
91
106
  };
107
+ worldMouse: {
108
+ x: number;
109
+ y: number;
110
+ };
92
111
  };
93
112
  export type HoverInEvent = {
94
113
  tag: "HOVER_IN";
@@ -97,6 +116,10 @@ export type HoverInEvent = {
97
116
  x: number;
98
117
  y: number;
99
118
  };
119
+ worldMouse: {
120
+ x: number;
121
+ y: number;
122
+ };
100
123
  };
101
124
  export type HoverOutEvent = {
102
125
  tag: "HOVER_OUT";
@@ -105,6 +128,10 @@ export type HoverOutEvent = {
105
128
  x: number;
106
129
  y: number;
107
130
  };
131
+ worldMouse: {
132
+ x: number;
133
+ y: number;
134
+ };
108
135
  };
109
136
  export type MouseMoveEvent = {
110
137
  tag: "MOUSE_MOVE";
@@ -113,6 +140,10 @@ export type MouseMoveEvent = {
113
140
  x: number;
114
141
  y: number;
115
142
  };
143
+ worldMouse: {
144
+ x: number;
145
+ y: number;
146
+ };
116
147
  };
117
148
  export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent;
118
149
  export type NextStateProps<State> = {
@@ -123,6 +154,9 @@ export type NextStateProps<State> = {
123
154
  isJustPressed: boolean;
124
155
  isJustReleased: boolean;
125
156
  }>;
157
+ playSound: (id: string) => void;
158
+ playMusic: (id: string) => void;
159
+ pauseMusic: () => void;
126
160
  };
127
161
  export declare const STOP: "Yuuna.STOP";
128
162
  export type NextStateFunction<State> = (props: NextStateProps<State>) => State | typeof STOP | undefined;
@@ -143,12 +177,28 @@ export type RunEngineProps<State> = {
143
177
  vertical: number;
144
178
  horizontal: number;
145
179
  };
180
+ animations?: Record<string, {
181
+ frames: number[];
182
+ frameDuration: number;
183
+ loop: boolean;
184
+ }>;
185
+ }>;
186
+ sounds?: Record<string, {
187
+ src: string;
188
+ }>;
189
+ music?: Record<string, {
190
+ src: string;
146
191
  }>;
147
192
  canvas?: {
148
193
  width?: number;
149
194
  height?: number;
150
195
  backgroundColor?: string;
151
196
  };
197
+ camera?: (state: State) => {
198
+ x: number;
199
+ y: number;
200
+ zoom: number;
201
+ };
152
202
  };
153
203
  export type RunEngineFunction = <State>(props: RunEngineProps<State>) => Promise<void>;
154
204
  export declare const keyboardKeys: readonly ["ControlLeft", "ControlRight", "AltLeft", "AltRight", "CapsLock", "End", "Delete", "Tab", "Space", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ"];
@@ -158,3 +208,4 @@ export declare var Yuuna: {
158
208
  runEngine: RunEngineFunction;
159
209
  STOP: typeof STOP;
160
210
  };
211
+ export {};