fruta 0.0.3 → 0.1.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/LICENSE +21 -0
- package/README.md +367 -26
- package/dist/animation/anim.d.ts +13 -0
- package/dist/animation/animate.d.ts +101 -0
- package/dist/audio/audio.d.ts +29 -0
- package/dist/fruta.d.ts +302 -0
- package/dist/fruta.js +19 -0
- package/dist/frutaGl.d.ts +277 -0
- package/dist/game/behaviors.d.ts +38 -0
- package/dist/game/charts.d.ts +127 -0
- package/dist/game/play.d.ts +4 -0
- package/dist/game/project.d.ts +54 -0
- package/dist/input/gamepad.d.ts +38 -0
- package/dist/math/math.d.ts +37 -0
- package/dist/math/pathfind.d.ts +7 -0
- package/dist/math/physics.d.ts +38 -0
- package/dist/math/pool.d.ts +11 -0
- package/dist/math/spatial.d.ts +10 -0
- package/dist/render/create/FontCreator.d.ts +16 -0
- package/dist/render/create/SaveRestore.d.ts +5 -0
- package/dist/render/create/ShapeCreator.d.ts +49 -0
- package/dist/render/create/canvasTarget.d.ts +5 -0
- package/dist/render/shaders.d.ts +29 -0
- package/dist/render/webgl.d.ts +67 -0
- package/dist/renderer.d.ts +42 -0
- package/dist/types.d.ts +9 -0
- package/dist/utils/logStyle.d.ts +1 -0
- package/dist/world/camera.d.ts +24 -0
- package/dist/world/entities.d.ts +45 -0
- package/dist/world/particles.d.ts +42 -0
- package/dist/world/tilemap.d.ts +44 -0
- package/dist/world/timers.d.ts +11 -0
- package/package.json +35 -35
- package/DOCUMENTATION.MD +0 -874
- package/dist/main.js +0 -1
- package/index.html +0 -9
- package/settings.json +0 -6
- package/src/core/create/_fontCreator.js +0 -11
- package/src/core/create/_saveOrRestore.js +0 -22
- package/src/core/create/_shapeCreator.js +0 -20
- package/src/core/create/fontCreatorMixin.js +0 -167
- package/src/core/create/shapeCreatorMixin.js +0 -656
- package/src/core/fruta.js +0 -22
- package/src/core/game/game.js +0 -30
- package/src/core/game/scene.js +0 -29
- package/src/core/game/tick.js +0 -42
- package/src/core/utils/logStyle.js +0 -8
- package/src/core/utils/utils.js +0 -0
- package/src/methods/constants.js +0 -0
- package/src/methods/creator/_scene.js +0 -0
- package/src/methods/creator/creator.js +0 -0
- package/webpack.config.js +0 -47
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Jhornan Colina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,48 +1,389 @@
|
|
|
1
|
-
# Fruta 🍒
|
|
1
|
+
# Fruta 🍒
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/fruta)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
**A tiny, semantic 2D graphics & game engine for the web.** One friendly object,
|
|
11
|
+
intent-named calls, sensible defaults. The goal: easier to learn than Phaser or Pixi,
|
|
12
|
+
while doing *real* games — sprites, tilemaps, **physics, pathfinding, collision, pooling**,
|
|
13
|
+
camera, scenes, audio, particles, entities, charts, and WebGL shaders.
|
|
6
14
|
|
|
7
|
-
|
|
15
|
+
> **Written in TypeScript, types ship with the package** — every call, option and return value
|
|
16
|
+
> is typed, so your editor autocompletes the whole API and catches mistakes before you run.
|
|
17
|
+
> <picture><img alt="TypeScript" src="https://img.shields.io/badge/-3178C6?logo=typescript&logoColor=white" height="14"></picture>
|
|
8
18
|
|
|
9
|
-
```
|
|
10
|
-
|
|
19
|
+
```ts
|
|
20
|
+
import Fruta from 'fruta'
|
|
21
|
+
|
|
22
|
+
const fruta = Fruta({ width: 500, height: 500 }).mount()
|
|
23
|
+
|
|
24
|
+
fruta.loop((dt, t) => {
|
|
25
|
+
fruta.background('#111')
|
|
26
|
+
fruta.circle({ x: fruta.mouse.x, y: fruta.mouse.y, r: 24, fill: 'tomato' })
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **One object.** `const fruta = Fruta(...)` — everything hangs off it, fully typed.
|
|
31
|
+
- **Semantic.** `fruta.circle({ x, y, r, fill })`, not `ctx.beginPath()…`.
|
|
32
|
+
- **Immediate by default, retained when you want it** (`fruta.add(...)`).
|
|
33
|
+
- **Two backends, one API.** Canvas2D for the richest features; `Fruta.gl(...)` (WebGL) for tens of thousands of sprites — same sugar.
|
|
34
|
+
- **No build lock-in.** Bring your own `<canvas>` — works in React/Vue/Svelte or plain HTML.
|
|
35
|
+
|
|
36
|
+
> See it all running: clone the repo and `bun run dev` for the demo playground (platformers,
|
|
37
|
+
> a bullet-hell scale test, a Terraria-like chunked world, a Noita-like falling-sand sim, a
|
|
38
|
+
> Factorio-like factory, a level editor, an animation editor, and more — see **What you can build**).
|
|
39
|
+
> Recipes: [EXAMPLES.MD](EXAMPLES.MD) · Changes: [CHANGELOG.md](CHANGELOG.md).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm i fruta # or: bun add fruta
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
ESM-only, zero dependencies, tree-shakeable (`sideEffects: false`), types bundled.
|
|
50
|
+
|
|
51
|
+
## Setup
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const fruta = Fruta({ width, height, background?, clear?, mount?, canvas? })
|
|
55
|
+
|
|
56
|
+
fruta.mount(parent?) // append the canvas (defaults to <body>)
|
|
57
|
+
fruta.canvas // the HTMLCanvasElement
|
|
58
|
+
fruta.context // the raw CanvasRenderingContext2D (escape hatch)
|
|
59
|
+
fruta.destroy() // stop the loop + remove listeners (call on unmount)
|
|
11
60
|
```
|
|
12
61
|
|
|
13
|
-
|
|
62
|
+
Pass `{ canvas: el }` (element or CSS selector) to use your own canvas — the clean path for
|
|
63
|
+
frameworks. Omit it and Fruta makes one you `.mount()` yourself, or pass `{ mount: el }` to
|
|
64
|
+
attach it on creation.
|
|
14
65
|
|
|
66
|
+
If you set `background`, Fruta **repaints it at the start of every frame**, so your `loop`
|
|
67
|
+
body is just the scene. For trails or manual fades, opt out with `{ clear: false }`.
|
|
68
|
+
|
|
69
|
+
## The loop
|
|
70
|
+
|
|
71
|
+
`loop(fn)` runs every frame. `dt` is seconds since the last frame (multiply movement by it
|
|
72
|
+
and speed is identical on any screen); `t` is seconds since start.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
let x = 0
|
|
76
|
+
fruta.loop((dt, t) => {
|
|
77
|
+
fruta.background('#111')
|
|
78
|
+
x += 200 * dt // 200 px/second
|
|
79
|
+
fruta.rect({ x, y: 100, w: 40, h: 40, fill: 'deepskyblue' })
|
|
80
|
+
})
|
|
81
|
+
fruta.stop()
|
|
82
|
+
|
|
83
|
+
fruta.timeScale = 0.2 // global slow-mo / freeze (0 = frozen) — hit-stop, bullet-time, Bash
|
|
15
84
|
```
|
|
16
|
-
import Fruta from "fruta"
|
|
17
85
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
86
|
+
## Drawing
|
|
87
|
+
|
|
88
|
+
Every shape takes a typed options object with sensible defaults (no `fill`/`stroke` → black fill).
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
fruta.rect({ x, y, w, h, fill, stroke, strokeWidth, radius, rotation }) // rotation in degrees
|
|
92
|
+
fruta.circle({ x, y, r, fill, stroke, strokeWidth }) // x,y = center
|
|
93
|
+
fruta.ellipse({ x, y, rx, ry, rotation, fill, stroke, strokeWidth })
|
|
94
|
+
fruta.line({ x1, y1, x2, y2, stroke, strokeWidth })
|
|
95
|
+
fruta.polygon({ points: [{ x, y }, …], fill, stroke, strokeWidth, close })
|
|
96
|
+
fruta.text('hello', { x, y, fill, size, font, align, baseline, stroke, strokeWidth })
|
|
97
|
+
fruta.image('/pic.png', { x, y, w, h }) // cached + async; call inside loop()
|
|
98
|
+
fruta.background(color) · fruta.clear()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Gradients** (usable anywhere a `fill`/`stroke` goes), and **transforms** (`push`/`pop`,
|
|
102
|
+
nestable — translate, rotate in degrees, scale, fade):
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
fruta.radialGradient(x, y, r, [[0, '#fff'], [1, 'navy']])
|
|
106
|
+
fruta.linearGradient(x1, y1, x2, y2, [[0, 'red'], [1, 'gold']])
|
|
107
|
+
|
|
108
|
+
fruta.push({ x: 250, y: 250, rotate: angle, scale: 1.5, alpha: 0.8 })
|
|
109
|
+
fruta.circle({ x: 120, y: 0, r: 12, fill: 'deepskyblue' })
|
|
110
|
+
fruta.pop()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Animation
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// tween any numeric props (and colours!) over time → handle: pause()/resume()/cancel()
|
|
117
|
+
fruta.tween(obj, { to: { x: 300, fill: 'gold' }, duration: 0.8, ease: 'bounce',
|
|
118
|
+
delay, repeat, yoyo, onUpdate, onComplete })
|
|
25
119
|
|
|
26
|
-
|
|
120
|
+
fruta.stagger(items, { to, duration, ease, each }) // animate a group, offset by `each`s
|
|
121
|
+
|
|
122
|
+
fruta.timeline()
|
|
123
|
+
.to(a, { to: { x: 100 }, duration: 1 })
|
|
124
|
+
.to(b, { to: { y: 50 }, duration: 0.5 }, { at: 0 }) // { at } = absolute time (parallel)
|
|
125
|
+
.call(fn)
|
|
126
|
+
|
|
127
|
+
// named sprite-animation states
|
|
128
|
+
const anim = fruta.anim({ idle: { frames: [0], fps: 1 }, walk: { frames: [1, 2], fps: 9 } })
|
|
129
|
+
anim.play('walk', t)
|
|
130
|
+
fruta.frameAt(t, 8, 4) // quick looping frame: floor(t*fps) % count
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Easings:** `linear`, plus `easeIn/Out/InOut` and the families `quad · cubic · quart · sine ·
|
|
134
|
+
expo · circ · back · bounce · elastic` each as `…In` / `…Out` / `…InOut`. Or pass your own `(t) => number`.
|
|
135
|
+
|
|
136
|
+
## Input
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
fruta.keyDown('ArrowRight') // true while held (poll in loop)
|
|
140
|
+
fruta.onKey('Space', () => { … }) // on each press
|
|
141
|
+
|
|
142
|
+
fruta.mouse // { x, y } in canvas coords (correct under CSS scaling)
|
|
143
|
+
fruta.mouseDown
|
|
144
|
+
fruta.onClick(p => …) · fruta.onPress(p => …) · fruta.onRelease(p => …) · fruta.onMove(p => …)
|
|
145
|
+
|
|
146
|
+
const pad = fruta.gamepad({ buttons: { jump: 'A', shoot: 'X' }, deadzone: 0.12 })
|
|
147
|
+
pad.down('jump') · pad.pressed('jump') · pad.value('LT') · pad.stick('left')
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> Game keys (Space, arrows, Page/Home/End) no longer scroll the page — Fruta `preventDefault`s
|
|
151
|
+
> them (unless you're typing in a form field).
|
|
152
|
+
|
|
153
|
+
## Audio
|
|
154
|
+
|
|
155
|
+
Web Audio under the hood — low latency, overlapping playback, and a synth `beep` so you can make
|
|
156
|
+
sound with **zero asset files**. Auto-unlocks on first input.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
await fruta.load({ jump: 'jump.mp3' })
|
|
160
|
+
fruta.play('jump', { volume: 0.5, loop, rate }) // → handle: stop()/volume()
|
|
161
|
+
fruta.beep({ freq: 440, duration: 0.15, type: 'square', volume: 0.3 })
|
|
162
|
+
fruta.volume(0.8) · fruta.mute()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Collision & physics
|
|
166
|
+
|
|
167
|
+
**Detection** — overlap tests for rects (`{x,y,w,h}` top-left) and circles (`{x,y,r}` center), auto-detected:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
fruta.hits(a, b) // overlap?
|
|
171
|
+
fruta.inside(point, shape)
|
|
172
|
+
fruta.overlap(a, b, (a, b) => { … }) // a/b each one thing or a list; callback per overlapping pair
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Response** — push a moving body out of a solid (the arcade collision behind platformers):
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// fruta.solve(body, solid) → 'x' | 'y' | null; zeroes velocity into the surface, sets body.onGround
|
|
179
|
+
player.vy += GRAVITY * dt
|
|
180
|
+
player.x += player.vx * dt; player.y += player.vy * dt
|
|
181
|
+
player.onGround = false
|
|
182
|
+
for (const wall of solids) fruta.solve(player, wall)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**A real physics world** — `fruta.physics()` gives a `World`: AABB + circle bodies, gravity, and
|
|
186
|
+
body-vs-body collision (impulse solver with **friction + restitution + Baumgarte** stabilisation,
|
|
187
|
+
so stacks settle). The sugar: `world.add(obj)` **attaches physics to any object in place**.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const world = fruta.physics({ gravity: 1400 })
|
|
191
|
+
world.static(0, H - 20, W, 20) // walls/floors (mass 0)
|
|
192
|
+
const box = world.box(100, 0, 30, 30, { friction: 0.5, restitution: 0.2 })
|
|
193
|
+
const ball = world.circle(200, 0, 16, { restitution: 0.6 })
|
|
194
|
+
world.add(myPlayer, { mass: 1 }) // give physics to YOUR object
|
|
195
|
+
|
|
196
|
+
fruta.loop((dt) => { world.step(dt); for (const b of world.bodies) draw(b) })
|
|
197
|
+
```
|
|
27
198
|
|
|
28
|
-
|
|
199
|
+
## Pathfinding (A*)
|
|
29
200
|
|
|
201
|
+
```ts
|
|
202
|
+
import { findPath } from 'fruta'
|
|
203
|
+
const path = findPath({ x: 0, y: 0 }, { x: 9, y: 6 }, cols, rows, (x, y) => isWall(x, y))
|
|
204
|
+
// or on a tilemap, around solid tiles:
|
|
205
|
+
const route = map.path({ x: 2, y: 2 }, { x: 18, y: 9 }) // → [{x,y}, …] | null
|
|
30
206
|
```
|
|
31
207
|
|
|
32
|
-
|
|
208
|
+
## Scale: pooling & spatial hashing
|
|
209
|
+
|
|
210
|
+
The two patterns behind thousands of bullets/enemies at 60 fps — no per-frame allocation, near-O(n) collision.
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { Pool, SpatialHash } from 'fruta'
|
|
214
|
+
|
|
215
|
+
const bullets = new Pool(2000, () => ({ x: 0, y: 0, vx: 0, vy: 0, dead: false }))
|
|
216
|
+
const b = bullets.spawn() // next free slot (or null when full); reset its fields
|
|
217
|
+
bullets.kill(i) // swap-remove the active body at index i
|
|
218
|
+
|
|
219
|
+
const hash = new SpatialHash(32) // cell size
|
|
220
|
+
hash.clear(); for (const e of enemies) hash.insert(e.x, e.y, e)
|
|
221
|
+
const near: Enemy[] = []
|
|
222
|
+
hash.query(b.x, b.y, 12, near) // only the enemies in nearby cells
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Curves & hex grids
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { cubicBezier, bezierPath, hexToPixel, pixelToHex, hexNeighbors } from 'fruta'
|
|
229
|
+
|
|
230
|
+
cubicBezier(p0, p1, p2, p3, t) // a point on a cubic Bézier
|
|
231
|
+
const pts = bezierPath(p0, p1, p2, p3, 32) // sample it to a polyline (path / motion curve)
|
|
232
|
+
|
|
233
|
+
hexToPixel(q, r, size) // pointy-top axial → pixel
|
|
234
|
+
pixelToHex(x, y, size) // pixel → { q, r } (rounded)
|
|
235
|
+
hexNeighbors(q, r) // the 6 surrounding hexes
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Sprites & assets
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
await fruta.load({ hero: '/hero.png' }) // preload → loading screen
|
|
242
|
+
fruta.addImage('hero', imageOrCanvas) // register an image/canvas (e.g. a generated sheet)
|
|
243
|
+
fruta.sprite('hero', { x, y, w, h, frame, frameW, frameH, cols, rotation, anchor: 'center', flipX })
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Camera
|
|
247
|
+
|
|
248
|
+
Draw the world between `begin()`/`end()`; draw HUD after (screen space).
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
fruta.camera.follow(player, 0.14) // smooth follow (1 = instant)
|
|
252
|
+
fruta.camera.clamp(0, 0, world.w, world.h)
|
|
253
|
+
fruta.camera.zoom = 1.5
|
|
254
|
+
fruta.camera.begin(); /* world drawing */ fruta.camera.end()
|
|
255
|
+
fruta.camera.screenToWorld(fruta.mouse.x, fruta.mouse.y)
|
|
256
|
+
fruta.camera.shake(12, 0.3) // strength px, duration s
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Tilemaps & platformer physics
|
|
260
|
+
|
|
261
|
+
A grid level that draws itself and resolves arcade collisions against solid tiles.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const map = fruta.tilemap({
|
|
265
|
+
size: 32,
|
|
266
|
+
data: [' o ', '#####'], // rows of chars
|
|
267
|
+
tiles: { '#': { solid: true, color: '#7d4a26' } }, // or { sprite, frame }
|
|
268
|
+
bevel: true, // edge light/shadow for depth (autotiling-lite)
|
|
269
|
+
})
|
|
270
|
+
map.draw()
|
|
271
|
+
map.move(player, dt) // moves by vx/vy, resolves vs tiles, sets player.onGround
|
|
272
|
+
map.path({ x: 1, y: 1 }, { x: 9, y: 3 }) // A* around solid tiles
|
|
273
|
+
map.isSolid(tx, ty) · map.at/set(tx, ty)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Scenes, particles, timers, save
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
fruta.scene('game', { enter() { reset() }, update(dt, t) { … }, leave() { … } })
|
|
280
|
+
fruta.start('game', 0.4) // switch with a 0.4s fade
|
|
281
|
+
|
|
282
|
+
fruta.burst({ x, y, count: 30, color: ['#ff5', '#5cf'], speed: [40, 200], spread: 360, life: 1, gravity: 250 })
|
|
283
|
+
const trail = fruta.emit({ x, y, rate: 60, color: '#6cf', life: 0.6 }); trail.x = fruta.mouse.x
|
|
284
|
+
fruta.drawParticles()
|
|
285
|
+
|
|
286
|
+
fruta.after(2, () => { … }) // once → handle.cancel()
|
|
287
|
+
fruta.every(0.5, () => { … }) // repeating
|
|
288
|
+
fruta.store('highscore', 9000); fruta.stored('highscore', 0) // persist (localStorage, JSON)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Entities (opt-in retained mode)
|
|
292
|
+
|
|
293
|
+
Register an entity and the engine moves it (velocity + gravity + its own `update()`).
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
const ball = fruta.add({
|
|
297
|
+
shape: 'circle', x, y, r: 10, vx: 80, gravity: 520, color: 'tomato',
|
|
298
|
+
bounds: 'bounce', // 'bounce' off walls or 'wrap' across them
|
|
299
|
+
update(self, dt, t) { if (self.y > 400) self.vy *= -0.8 },
|
|
300
|
+
})
|
|
301
|
+
fruta.drawEntities() · fruta.all · ball.remove()
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Live debug & charts
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
fruta.debug(true) // overlay: FPS, entity/particle/tween counts, mouse
|
|
308
|
+
fruta.watch('player.x', player.x)
|
|
309
|
+
|
|
310
|
+
fruta.barChart({ data: [{ label: 'Mon', value: 40 }, …], max: 100 })
|
|
311
|
+
fruta.lineChart({ series: [{ name: 'Revenue', data: [...] }], points: true, legend: true })
|
|
312
|
+
fruta.pieChart({ data: [...], donut: 0.55 }) · fruta.radar({ axes, series }) · fruta.legend({ … })
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Charts handle scales/axes/legend; pass `progress` (0..1) for an animated reveal.
|
|
316
|
+
|
|
317
|
+
## WebGL: same sugar, GPU speed, shaders
|
|
318
|
+
|
|
319
|
+
For thousands of objects, switch to the WebGL renderer — **identical API**, GPU-batched:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const gl = Fruta.gl({ width: 800, height: 600, background: '#0a0a14' }).mount()
|
|
323
|
+
gl.loop((dt) => { gl.background(); for (const b of balls) gl.circle({ x: b.x, y: b.y, r: b.r, fill: b.color }) })
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`Fruta.gl` mirrors the full Canvas2D engine — draw, camera, input, **entities, tilemap + physics,
|
|
327
|
+
collision, particles, scenes, timers, save, audio, animation, `timeScale`, `solve`, `physics`** —
|
|
328
|
+
all GPU-batched. Plus **shaders, Fruta-style**:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
const bg = gl.shader(`void main(){ gl_FragColor = vec4(0.5+0.5*cos(uTime+gl_FragCoord.xyx), 1.0); }`)
|
|
332
|
+
gl.loop(() => bg.draw())
|
|
333
|
+
|
|
334
|
+
// post-processing: ~25 presets, one call
|
|
335
|
+
gl.effect('crt') · gl.effect('vignette', { intensity: 0.7 }) · gl.effect('chromatic') · gl.effect(null)
|
|
336
|
+
// or your own fragment (samples uScene at vUV):
|
|
337
|
+
gl.effect('uniform vec2 uCenter;void main(){ /* a custom shockwave */ }', { uCenter: [0.5, 0.5] })
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**~25 post presets:** color (`grayscale invert sepia tint brightness posterize threshold`),
|
|
341
|
+
retro (`scanlines crt vhs pixelate noise glitch`), distortion (`chromatic wave fisheye mirror
|
|
342
|
+
shockwave`), blur/glow (`blur sharpen bloom dream edge`), special (`vignette nightvision`).
|
|
343
|
+
|
|
344
|
+
> Canvas2D and WebGL can't share a canvas, so `Fruta.gl` is its own object. Both are full engines —
|
|
345
|
+
> pick Canvas2D for the richest 2D, WebGL for raw throughput. `WebGLBatch`/`Shader`/`PostFX` are exported.
|
|
346
|
+
|
|
347
|
+
## Going lower-level
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
fruta.context // raw CanvasRenderingContext2D
|
|
351
|
+
fruta.shapes // chainable Canvas wrappers
|
|
352
|
+
fruta.fonts // chainable text wrappers
|
|
353
|
+
fruta.state // context save/restore
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## What you can build
|
|
357
|
+
|
|
358
|
+
Fruta is small, but it's been stress-tested against a suite of real game/app slices (in the
|
|
359
|
+
playground) — these are the genres it comfortably handles today:
|
|
33
360
|
|
|
34
|
-
|
|
361
|
+
| You want… | Fruta gives you |
|
|
362
|
+
|---|---|
|
|
363
|
+
| **Platformers** (Ori-style) | `solve` collision, `timeScale` (hit-stop), tilemaps, camera, shaders for game-feel |
|
|
364
|
+
| **Bullet-hell / survivors** (thousands of entities) | `Pool` + `SpatialHash` + the GL batch renderer — 60 fps at thousands |
|
|
365
|
+
| **Top-down / colony sims** | `findPath` (A*) NPC AI, state machines, day/night, save/load |
|
|
366
|
+
| **Terraria-likes** (huge worlds) | chunked tile streaming, dynamic lighting, simple liquids, mining, procedural gen |
|
|
367
|
+
| **Noita-likes** (pixel physics) | cellular automata over a grid blitted as one texture (`context` + `drawImage`) |
|
|
368
|
+
| **Factorio-likes** (sim at scale) | a fixed-timestep loop + data-oriented machines (belts, inserters, recipes) |
|
|
369
|
+
| **Editor apps** (level / animation / node) | `onPress/onMove/onRelease/onKey` + `context` — zoom/pan, gizmos, undo/redo, timelines |
|
|
370
|
+
| **Data viz & dashboards** | one-call `barChart/lineChart/pieChart/radar` |
|
|
35
371
|
|
|
36
|
-
|
|
372
|
+
The playground (`bun run dev`) ships runnable versions of all of these.
|
|
37
373
|
|
|
38
|
-
|
|
374
|
+
## TypeScript
|
|
39
375
|
|
|
40
|
-
|
|
376
|
+
Fruta is **written in TypeScript and ships its own `.d.ts`** — no `@types/fruta` needed. Every
|
|
377
|
+
option object, return value and exported helper (`World`, `Pool`, `SpatialHash`, `findPath`,
|
|
378
|
+
`cubicBezier`, `hexToPixel`, `Vec`, `Hex`, …) is typed, so you get full autocomplete and
|
|
379
|
+
compile-time checks. It works just as well from plain JavaScript.
|
|
41
380
|
|
|
42
|
-
|
|
381
|
+
## Status
|
|
43
382
|
|
|
44
|
-
|
|
383
|
+
`0.1.0` — alpha, the API is settling but solid. Canvas2D is draw-call bound (fine into the low
|
|
384
|
+
thousands); `Fruta.gl` (WebGL, batched) is the throughput path for tens of thousands. See
|
|
385
|
+
[CHANGELOG.md](CHANGELOG.md) and [ROADMAP.md](ROADMAP.md).
|
|
45
386
|
|
|
46
|
-
|
|
387
|
+
## License
|
|
47
388
|
|
|
48
|
-
|
|
389
|
+
[MIT](https://opensource.org/license/mit/)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AnimState {
|
|
2
|
+
frames: number[];
|
|
3
|
+
fps: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class Anim {
|
|
6
|
+
private readonly states;
|
|
7
|
+
private current;
|
|
8
|
+
private startAt;
|
|
9
|
+
constructor(states: Record<string, AnimState>);
|
|
10
|
+
play(name: string, time: number): void;
|
|
11
|
+
frame(time: number): number;
|
|
12
|
+
get state(): string;
|
|
13
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type EaseFn = (t: number) => number;
|
|
2
|
+
export interface Updatable {
|
|
3
|
+
update(dt: number): boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare const EASES: {
|
|
6
|
+
linear: (t: number) => number;
|
|
7
|
+
easeIn: (t: number) => number;
|
|
8
|
+
easeOut: (t: number) => number;
|
|
9
|
+
easeInOut: (t: number) => number;
|
|
10
|
+
quadIn: (t: number) => number;
|
|
11
|
+
quadOut: (t: number) => number;
|
|
12
|
+
quadInOut: (t: number) => number;
|
|
13
|
+
cubicIn: (t: number) => number;
|
|
14
|
+
cubicOut: (t: number) => number;
|
|
15
|
+
cubicInOut: (t: number) => number;
|
|
16
|
+
quartIn: (t: number) => number;
|
|
17
|
+
quartOut: (t: number) => number;
|
|
18
|
+
quartInOut: (t: number) => number;
|
|
19
|
+
sineIn: (t: number) => number;
|
|
20
|
+
sineOut: (t: number) => number;
|
|
21
|
+
sineInOut: (t: number) => number;
|
|
22
|
+
expoIn: (t: number) => number;
|
|
23
|
+
expoOut: (t: number) => number;
|
|
24
|
+
expoInOut: (t: number) => number;
|
|
25
|
+
circIn: (t: number) => number;
|
|
26
|
+
circOut: (t: number) => number;
|
|
27
|
+
circInOut: (t: number) => number;
|
|
28
|
+
backIn: (t: number) => number;
|
|
29
|
+
backOut: (t: number) => number;
|
|
30
|
+
backInOut: (t: number) => number;
|
|
31
|
+
bounceIn: (t: number) => number;
|
|
32
|
+
bounceOut: EaseFn;
|
|
33
|
+
bounceInOut: (t: number) => number;
|
|
34
|
+
elasticIn: (t: number) => number;
|
|
35
|
+
elasticOut: EaseFn;
|
|
36
|
+
elasticInOut: (t: number) => number;
|
|
37
|
+
bounce: EaseFn;
|
|
38
|
+
elastic: EaseFn;
|
|
39
|
+
};
|
|
40
|
+
export type EaseName = keyof typeof EASES;
|
|
41
|
+
export type RGBA = [number, number, number, number];
|
|
42
|
+
export declare function parseColor(c: string): RGBA;
|
|
43
|
+
export declare function lerpColor(a: RGBA, b: RGBA, t: number): string;
|
|
44
|
+
export interface TweenSpec {
|
|
45
|
+
to: Record<string, number>;
|
|
46
|
+
duration: number;
|
|
47
|
+
ease: EaseFn;
|
|
48
|
+
delay: number;
|
|
49
|
+
repeat: number;
|
|
50
|
+
yoyo: boolean;
|
|
51
|
+
onUpdate?: (target: Record<string, number>, progress: number) => void;
|
|
52
|
+
onComplete?: () => void;
|
|
53
|
+
}
|
|
54
|
+
export declare class Tween implements Updatable {
|
|
55
|
+
private readonly target;
|
|
56
|
+
private readonly spec;
|
|
57
|
+
private from;
|
|
58
|
+
private captured;
|
|
59
|
+
private elapsed;
|
|
60
|
+
private cancelled;
|
|
61
|
+
private paused;
|
|
62
|
+
constructor(target: Record<string, number>, spec: TweenSpec);
|
|
63
|
+
update(dt: number): boolean;
|
|
64
|
+
cancel(): void;
|
|
65
|
+
pause(): void;
|
|
66
|
+
resume(): void;
|
|
67
|
+
}
|
|
68
|
+
type TweenFactory = (target: Record<string, number>, opts: {
|
|
69
|
+
to?: Record<string, number | string>;
|
|
70
|
+
duration: number;
|
|
71
|
+
ease?: EaseName | EaseFn;
|
|
72
|
+
delay?: number;
|
|
73
|
+
repeat?: number;
|
|
74
|
+
yoyo?: boolean;
|
|
75
|
+
onUpdate?: (t: any, p: number) => void;
|
|
76
|
+
onComplete?: () => void;
|
|
77
|
+
}, absoluteDelay: number) => Tween;
|
|
78
|
+
export interface TimelinePlace {
|
|
79
|
+
at?: number;
|
|
80
|
+
}
|
|
81
|
+
export declare class Timeline implements Updatable {
|
|
82
|
+
private readonly factory;
|
|
83
|
+
private children;
|
|
84
|
+
private cursor;
|
|
85
|
+
private finishedCb?;
|
|
86
|
+
constructor(factory: TweenFactory);
|
|
87
|
+
to(target: Record<string, number>, opts: {
|
|
88
|
+
to: Record<string, number | string>;
|
|
89
|
+
duration: number;
|
|
90
|
+
ease?: EaseName | EaseFn;
|
|
91
|
+
delay?: number;
|
|
92
|
+
repeat?: number;
|
|
93
|
+
yoyo?: boolean;
|
|
94
|
+
onUpdate?: (t: any, p: number) => void;
|
|
95
|
+
onComplete?: () => void;
|
|
96
|
+
}, place?: TimelinePlace): this;
|
|
97
|
+
call(fn: () => void, place?: TimelinePlace): this;
|
|
98
|
+
then(fn: () => void): this;
|
|
99
|
+
update(dt: number): boolean;
|
|
100
|
+
}
|
|
101
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface PlayOptions {
|
|
2
|
+
volume?: number;
|
|
3
|
+
loop?: boolean;
|
|
4
|
+
rate?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface BeepOptions {
|
|
7
|
+
freq?: number;
|
|
8
|
+
duration?: number;
|
|
9
|
+
type?: OscillatorType;
|
|
10
|
+
volume?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Sound {
|
|
13
|
+
stop(): void;
|
|
14
|
+
volume(v: number): void;
|
|
15
|
+
}
|
|
16
|
+
export declare class AudioManager {
|
|
17
|
+
private ctx;
|
|
18
|
+
private master;
|
|
19
|
+
private buffers;
|
|
20
|
+
private muted;
|
|
21
|
+
private level;
|
|
22
|
+
private ensure;
|
|
23
|
+
resume(): void;
|
|
24
|
+
loadSound(name: string, src: string): Promise<void>;
|
|
25
|
+
play(name: string, opts?: PlayOptions): Sound;
|
|
26
|
+
beep(opts?: BeepOptions): void;
|
|
27
|
+
volume(v: number): void;
|
|
28
|
+
mute(on?: boolean): void;
|
|
29
|
+
}
|