cubeforge 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 +86 -0
- package/dist/index.d.mts +7 -7
- package/dist/index.js +873 -6
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# cubeforge
|
|
2
|
+
|
|
3
|
+
**Build browser games with React.**
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<Game width={800} height={500} gravity={980}>
|
|
7
|
+
<World background="#1a1a2e">
|
|
8
|
+
<Camera2D followEntity="player" smoothing={0.85} />
|
|
9
|
+
<Entity id="player" tags={['player']}>
|
|
10
|
+
<Transform x={100} y={300} />
|
|
11
|
+
<Sprite width={32} height={48} color="#4fc3f7" />
|
|
12
|
+
<RigidBody />
|
|
13
|
+
<BoxCollider width={32} height={48} />
|
|
14
|
+
<Script update={playerUpdate} />
|
|
15
|
+
</Entity>
|
|
16
|
+
<Entity tags={['ground']}>
|
|
17
|
+
<Transform x={400} y={480} />
|
|
18
|
+
<Sprite width={800} height={32} color="#37474f" />
|
|
19
|
+
<RigidBody isStatic />
|
|
20
|
+
<BoxCollider width={800} height={32} />
|
|
21
|
+
</Entity>
|
|
22
|
+
</World>
|
|
23
|
+
</Game>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install cubeforge react react-dom
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What's included
|
|
33
|
+
|
|
34
|
+
- **ECS** — archetype-based entity-component-system with query caching
|
|
35
|
+
- **Physics** — two-pass AABB, capsule colliders, kinematic bodies, one-way platforms, 60 Hz fixed timestep
|
|
36
|
+
- **Renderer** — WebGL2 instanced renderer by default; Canvas2D opt-in via `renderer={Canvas2DRenderSystem}`
|
|
37
|
+
- **Input** — keyboard, mouse, gamepad, per-player input maps, input contexts, recording/playback
|
|
38
|
+
- **Audio** — Web Audio API with volume groups, fade, crossfade, ducking (`useSound`)
|
|
39
|
+
- **Gameplay hooks** — `usePlatformerController`, `useTopDownMovement`, `useHealth`, `useSave`, `useGameStateMachine`, `useLevelTransition`, `usePathfinding`, `useAISteering`, and more
|
|
40
|
+
- **DevTools** — time-travel frame scrubber and entity inspector (`<Game devtools>`)
|
|
41
|
+
- **Deterministic mode** — seeded RNG for reproducible simulations (`<Game deterministic seed={n}>`)
|
|
42
|
+
|
|
43
|
+
## Quick example
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
import {
|
|
47
|
+
Game, World, Entity, Transform, Sprite,
|
|
48
|
+
RigidBody, BoxCollider, Script,
|
|
49
|
+
usePlatformerController, useHealth, useSound,
|
|
50
|
+
} from 'cubeforge'
|
|
51
|
+
|
|
52
|
+
function Player() {
|
|
53
|
+
const id = useEntity()
|
|
54
|
+
usePlatformerController(id, { speed: 220, jumpForce: -520, maxJumps: 2 })
|
|
55
|
+
const { hp, takeDamage } = useHealth(5, { onDeath: () => console.log('dead') })
|
|
56
|
+
const jump = useSound('/jump.wav', { group: 'sfx' })
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function MyGame() {
|
|
61
|
+
return (
|
|
62
|
+
<Game width={800} height={500} gravity={980}>
|
|
63
|
+
<World background="#1a1a2e">
|
|
64
|
+
<Camera2D followEntity="player" smoothing={0.87} />
|
|
65
|
+
<Entity id="player" tags={['player']}>
|
|
66
|
+
<Transform x={100} y={300} />
|
|
67
|
+
<Sprite src="/player.png" width={32} height={48} />
|
|
68
|
+
<RigidBody />
|
|
69
|
+
<BoxCollider width={30} height={48} />
|
|
70
|
+
<Player />
|
|
71
|
+
</Entity>
|
|
72
|
+
</World>
|
|
73
|
+
</Game>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Links
|
|
79
|
+
|
|
80
|
+
- [Documentation](https://cubeforge.dev)
|
|
81
|
+
- [Examples](https://github.com/1homsi/cubeforge-examples) — 10 runnable games
|
|
82
|
+
- [GitHub](https://github.com/1homsi/cubeforge)
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/dist/index.d.mts
CHANGED
|
@@ -10,8 +10,9 @@ export { EngineState, useCircleEnter, useCircleExit, useCircleStay, useCollision
|
|
|
10
10
|
export { AISteering, AnimationClip, AnimationControllerResult, BindingControls, GameState as GameStateDefinition, GameStateMachineResult, HealthControls, HealthOptions, KinematicBodyControls, LevelTransitionControls, PathfindingControls, PlatformerControllerOptions, RestartControls, SaveControls, SaveOptions, TopDownMovementOptions, TransitionOptions, TransitionType, useAISteering, useAnimationController, useDamageZone, useDropThrough, useGameStateMachine, useHealth, useKinematicBody, useLevelTransition, usePathfinding, usePersistedBindings, usePlatformerController, useRestart, useSave, useTopDownMovement } from '@cubeforge/gameplay';
|
|
11
11
|
export { AudioGroup, SoundControls, duck, getGroupVolume, setGroupMute, setGroupVolume, setMasterVolume, stopGroup, useSound } from '@cubeforge/audio';
|
|
12
12
|
export { DevToolsHandle } from '@cubeforge/devtools';
|
|
13
|
+
export { AnimationStateComponent, RenderSystem as Canvas2DRenderSystem, ParallaxLayerComponent, Particle, ParticlePoolComponent, SpriteComponent, SquashStretchComponent, TextComponent, TrailComponent, createSprite } from '@cubeforge/renderer';
|
|
14
|
+
export { WebGLRenderSystem } from '@cubeforge/webgl-renderer';
|
|
13
15
|
export { BoxColliderComponent, CapsuleColliderComponent, CircleColliderComponent, RaycastHit, RigidBodyComponent, overlapBox, overlapCircle, raycast, raycastAll, sweepBox } from '@cubeforge/physics';
|
|
14
|
-
export { AnimationStateComponent, ParallaxLayerComponent, Particle, ParticlePoolComponent, SpriteComponent, SquashStretchComponent, TextComponent, TrailComponent, createSprite } from '@cubeforge/renderer';
|
|
15
16
|
|
|
16
17
|
interface GameControls {
|
|
17
18
|
pause(): void;
|
|
@@ -50,13 +51,12 @@ interface GameProps {
|
|
|
50
51
|
/** Custom plugins to register after core systems. Each plugin's systems run after Render. */
|
|
51
52
|
plugins?: Plugin[];
|
|
52
53
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* Example: `import { WebGLRenderSystem } from '@cubeforge/webgl-renderer'`
|
|
54
|
+
* Renderer to use (default: WebGL2).
|
|
55
|
+
* - omit or undefined — WebGL2 instanced renderer (default)
|
|
56
|
+
* - 'canvas2d' — Canvas2D renderer (opt-in for compatibility or pixel art)
|
|
57
|
+
* - CustomClass — any class implementing System with (canvas, entityIds) constructor
|
|
58
58
|
*/
|
|
59
|
-
renderer?: new (canvas: HTMLCanvasElement, entityIds: Map<string, EntityId>) => System;
|
|
59
|
+
renderer?: 'canvas2d' | (new (canvas: HTMLCanvasElement, entityIds: Map<string, EntityId>) => System);
|
|
60
60
|
style?: CSSProperties;
|
|
61
61
|
className?: string;
|
|
62
62
|
children?: React__default.ReactNode;
|
package/dist/index.js
CHANGED
|
@@ -2795,6 +2795,7 @@ var DebugSystem = class {
|
|
|
2795
2795
|
fps = 0;
|
|
2796
2796
|
update(world, dt) {
|
|
2797
2797
|
const { ctx, canvas } = this.renderer;
|
|
2798
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
2798
2799
|
this.frameCount++;
|
|
2799
2800
|
this.lastFpsTime += dt;
|
|
2800
2801
|
if (this.lastFpsTime >= 0.5) {
|
|
@@ -2887,6 +2888,816 @@ var DebugSystem = class {
|
|
|
2887
2888
|
}
|
|
2888
2889
|
};
|
|
2889
2890
|
|
|
2891
|
+
// ../../packages/webgl-renderer/src/shaders.ts
|
|
2892
|
+
var VERT_SRC = `#version 300 es
|
|
2893
|
+
layout(location = 0) in vec2 a_quadPos;
|
|
2894
|
+
layout(location = 1) in vec2 a_uv;
|
|
2895
|
+
|
|
2896
|
+
layout(location = 2) in vec2 i_pos;
|
|
2897
|
+
layout(location = 3) in vec2 i_size;
|
|
2898
|
+
layout(location = 4) in float i_rot;
|
|
2899
|
+
layout(location = 5) in vec2 i_anchor;
|
|
2900
|
+
layout(location = 6) in vec2 i_offset;
|
|
2901
|
+
layout(location = 7) in float i_flipX;
|
|
2902
|
+
layout(location = 8) in vec4 i_color;
|
|
2903
|
+
layout(location = 9) in vec4 i_uvRect;
|
|
2904
|
+
|
|
2905
|
+
uniform vec2 u_camPos;
|
|
2906
|
+
uniform float u_zoom;
|
|
2907
|
+
uniform vec2 u_canvasSize;
|
|
2908
|
+
uniform vec2 u_shake;
|
|
2909
|
+
|
|
2910
|
+
out vec2 v_uv;
|
|
2911
|
+
out vec4 v_color;
|
|
2912
|
+
|
|
2913
|
+
void main() {
|
|
2914
|
+
// Local position: map quad corner (-0.5..0.5) to draw rect, applying anchor
|
|
2915
|
+
vec2 local = (a_quadPos - vec2(i_anchor.x - 0.5, i_anchor.y - 0.5)) * i_size + i_offset;
|
|
2916
|
+
|
|
2917
|
+
// Horizontal flip
|
|
2918
|
+
if (i_flipX > 0.5) local.x = -local.x;
|
|
2919
|
+
|
|
2920
|
+
// Rotate around local origin
|
|
2921
|
+
float c = cos(i_rot);
|
|
2922
|
+
float s = sin(i_rot);
|
|
2923
|
+
local = vec2(c * local.x - s * local.y, s * local.x + c * local.y);
|
|
2924
|
+
|
|
2925
|
+
// World position
|
|
2926
|
+
vec2 world = i_pos + local;
|
|
2927
|
+
|
|
2928
|
+
// Camera \u2192 NDC clip space (Y is flipped: canvas Y down, WebGL Y up)
|
|
2929
|
+
// Equivalent to Canvas2D: translate(W/2 - camX*zoom + shakeX, H/2 - camY*zoom + shakeY); scale(zoom,zoom)
|
|
2930
|
+
float cx = 2.0 * u_zoom / u_canvasSize.x * (world.x - u_camPos.x) + 2.0 * u_shake.x / u_canvasSize.x;
|
|
2931
|
+
float cy = -2.0 * u_zoom / u_canvasSize.y * (world.y - u_camPos.y) - 2.0 * u_shake.y / u_canvasSize.y;
|
|
2932
|
+
|
|
2933
|
+
gl_Position = vec4(cx, cy, 0.0, 1.0);
|
|
2934
|
+
|
|
2935
|
+
// Remap UV [0,1] to the sub-rect defined by i_uvRect
|
|
2936
|
+
v_uv = i_uvRect.xy + a_uv * i_uvRect.zw;
|
|
2937
|
+
v_color = i_color;
|
|
2938
|
+
}
|
|
2939
|
+
`;
|
|
2940
|
+
var FRAG_SRC = `#version 300 es
|
|
2941
|
+
precision mediump float;
|
|
2942
|
+
|
|
2943
|
+
in vec2 v_uv;
|
|
2944
|
+
in vec4 v_color;
|
|
2945
|
+
|
|
2946
|
+
uniform sampler2D u_texture;
|
|
2947
|
+
uniform int u_useTexture;
|
|
2948
|
+
|
|
2949
|
+
out vec4 fragColor;
|
|
2950
|
+
|
|
2951
|
+
void main() {
|
|
2952
|
+
if (u_useTexture == 1) {
|
|
2953
|
+
fragColor = texture(u_texture, v_uv) * v_color;
|
|
2954
|
+
} else {
|
|
2955
|
+
fragColor = v_color;
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
`;
|
|
2959
|
+
var PARALLAX_VERT_SRC = `#version 300 es
|
|
2960
|
+
layout(location = 0) in vec2 a_pos;
|
|
2961
|
+
|
|
2962
|
+
out vec2 v_fragCoord;
|
|
2963
|
+
|
|
2964
|
+
void main() {
|
|
2965
|
+
gl_Position = vec4(a_pos, 0.0, 1.0);
|
|
2966
|
+
// Convert NDC (-1..1) to canvas pixel coords (0..canvasSize) in the frag shader
|
|
2967
|
+
v_fragCoord = a_pos * 0.5 + 0.5; // 0..1 normalized screen coord
|
|
2968
|
+
}
|
|
2969
|
+
`;
|
|
2970
|
+
var PARALLAX_FRAG_SRC = `#version 300 es
|
|
2971
|
+
precision mediump float;
|
|
2972
|
+
|
|
2973
|
+
in vec2 v_fragCoord;
|
|
2974
|
+
|
|
2975
|
+
uniform sampler2D u_texture;
|
|
2976
|
+
uniform vec2 u_uvOffset;
|
|
2977
|
+
uniform vec2 u_texSize; // texture size in pixels
|
|
2978
|
+
uniform vec2 u_canvasSize; // canvas size in pixels
|
|
2979
|
+
|
|
2980
|
+
out vec4 fragColor;
|
|
2981
|
+
|
|
2982
|
+
void main() {
|
|
2983
|
+
// Screen pixel position
|
|
2984
|
+
vec2 screenPx = v_fragCoord * u_canvasSize;
|
|
2985
|
+
// Tile: offset by uvOffset and wrap
|
|
2986
|
+
vec2 uv = mod((screenPx / u_texSize + u_uvOffset), 1.0);
|
|
2987
|
+
// Y must be flipped because WebGL origin is bottom-left but canvas is top-left
|
|
2988
|
+
uv.y = 1.0 - uv.y;
|
|
2989
|
+
fragColor = texture(u_texture, uv);
|
|
2990
|
+
}
|
|
2991
|
+
`;
|
|
2992
|
+
|
|
2993
|
+
// ../../packages/webgl-renderer/src/colorParser.ts
|
|
2994
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2995
|
+
function parseCSSColor(css) {
|
|
2996
|
+
const hit = cache.get(css);
|
|
2997
|
+
if (hit) return hit;
|
|
2998
|
+
let result = [1, 1, 1, 1];
|
|
2999
|
+
if (css.startsWith("#")) {
|
|
3000
|
+
const h = css.slice(1);
|
|
3001
|
+
if (h.length === 3 || h.length === 4) {
|
|
3002
|
+
const r = parseInt(h[0] + h[0], 16) / 255;
|
|
3003
|
+
const g = parseInt(h[1] + h[1], 16) / 255;
|
|
3004
|
+
const b = parseInt(h[2] + h[2], 16) / 255;
|
|
3005
|
+
const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
|
|
3006
|
+
result = [r, g, b, a];
|
|
3007
|
+
} else if (h.length === 6 || h.length === 8) {
|
|
3008
|
+
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
3009
|
+
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
3010
|
+
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
3011
|
+
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
3012
|
+
result = [r, g, b, a];
|
|
3013
|
+
}
|
|
3014
|
+
} else {
|
|
3015
|
+
const m = css.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
|
3016
|
+
if (m) {
|
|
3017
|
+
result = [
|
|
3018
|
+
parseInt(m[1]) / 255,
|
|
3019
|
+
parseInt(m[2]) / 255,
|
|
3020
|
+
parseInt(m[3]) / 255,
|
|
3021
|
+
m[4] !== void 0 ? parseFloat(m[4]) : 1
|
|
3022
|
+
];
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
cache.set(css, result);
|
|
3026
|
+
return result;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// ../../packages/webgl-renderer/src/webglRenderSystem.ts
|
|
3030
|
+
var FLOATS_PER_INSTANCE = 18;
|
|
3031
|
+
var MAX_INSTANCES = 8192;
|
|
3032
|
+
var MAX_TEXT_CACHE = 200;
|
|
3033
|
+
function compileShader(gl, type, src) {
|
|
3034
|
+
const shader = gl.createShader(type);
|
|
3035
|
+
gl.shaderSource(shader, src);
|
|
3036
|
+
gl.compileShader(shader);
|
|
3037
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
3038
|
+
throw new Error(`[WebGLRenderer] Shader compile error:
|
|
3039
|
+
${gl.getShaderInfoLog(shader)}`);
|
|
3040
|
+
}
|
|
3041
|
+
return shader;
|
|
3042
|
+
}
|
|
3043
|
+
function createProgram(gl, vertSrc, fragSrc) {
|
|
3044
|
+
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
|
|
3045
|
+
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
|
|
3046
|
+
const prog = gl.createProgram();
|
|
3047
|
+
gl.attachShader(prog, vert);
|
|
3048
|
+
gl.attachShader(prog, frag);
|
|
3049
|
+
gl.linkProgram(prog);
|
|
3050
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
3051
|
+
throw new Error(`[WebGLRenderer] Program link error:
|
|
3052
|
+
${gl.getProgramInfoLog(prog)}`);
|
|
3053
|
+
}
|
|
3054
|
+
gl.deleteShader(vert);
|
|
3055
|
+
gl.deleteShader(frag);
|
|
3056
|
+
return prog;
|
|
3057
|
+
}
|
|
3058
|
+
function createWhiteTexture(gl) {
|
|
3059
|
+
const tex = gl.createTexture();
|
|
3060
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3061
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]));
|
|
3062
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
3063
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
3064
|
+
return tex;
|
|
3065
|
+
}
|
|
3066
|
+
function getTextureKey(sprite) {
|
|
3067
|
+
if (sprite.image?.src) return sprite.image.src;
|
|
3068
|
+
if (sprite.src) return sprite.src;
|
|
3069
|
+
return `__color__:${sprite.color}`;
|
|
3070
|
+
}
|
|
3071
|
+
function getUVRect(sprite) {
|
|
3072
|
+
if (!sprite.image || sprite.image.naturalWidth === 0) return [0, 0, 1, 1];
|
|
3073
|
+
const iw = sprite.image.naturalWidth;
|
|
3074
|
+
const ih = sprite.image.naturalHeight;
|
|
3075
|
+
if (sprite.frameWidth && sprite.frameHeight) {
|
|
3076
|
+
const cols = sprite.frameColumns ?? Math.floor(iw / sprite.frameWidth);
|
|
3077
|
+
const col = sprite.frameIndex % cols;
|
|
3078
|
+
const row = Math.floor(sprite.frameIndex / cols);
|
|
3079
|
+
return [
|
|
3080
|
+
col * sprite.frameWidth / iw,
|
|
3081
|
+
row * sprite.frameHeight / ih,
|
|
3082
|
+
sprite.frameWidth / iw,
|
|
3083
|
+
sprite.frameHeight / ih
|
|
3084
|
+
];
|
|
3085
|
+
}
|
|
3086
|
+
if (sprite.frame) {
|
|
3087
|
+
const { sx, sy, sw, sh } = sprite.frame;
|
|
3088
|
+
return [sx / iw, sy / ih, sw / iw, sh / ih];
|
|
3089
|
+
}
|
|
3090
|
+
return [0, 0, 1, 1];
|
|
3091
|
+
}
|
|
3092
|
+
var WebGLRenderSystem = class {
|
|
3093
|
+
constructor(canvas, entityIds) {
|
|
3094
|
+
this.canvas = canvas;
|
|
3095
|
+
this.entityIds = entityIds;
|
|
3096
|
+
const gl = canvas.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false });
|
|
3097
|
+
if (!gl) throw new Error("[WebGLRenderer] WebGL2 is not supported in this browser");
|
|
3098
|
+
this.gl = gl;
|
|
3099
|
+
this.program = createProgram(gl, VERT_SRC, FRAG_SRC);
|
|
3100
|
+
const quadVerts = new Float32Array([
|
|
3101
|
+
-0.5,
|
|
3102
|
+
-0.5,
|
|
3103
|
+
0,
|
|
3104
|
+
0,
|
|
3105
|
+
0.5,
|
|
3106
|
+
-0.5,
|
|
3107
|
+
1,
|
|
3108
|
+
0,
|
|
3109
|
+
-0.5,
|
|
3110
|
+
0.5,
|
|
3111
|
+
0,
|
|
3112
|
+
1,
|
|
3113
|
+
0.5,
|
|
3114
|
+
-0.5,
|
|
3115
|
+
1,
|
|
3116
|
+
0,
|
|
3117
|
+
0.5,
|
|
3118
|
+
0.5,
|
|
3119
|
+
1,
|
|
3120
|
+
1,
|
|
3121
|
+
-0.5,
|
|
3122
|
+
0.5,
|
|
3123
|
+
0,
|
|
3124
|
+
1
|
|
3125
|
+
]);
|
|
3126
|
+
this.quadVAO = gl.createVertexArray();
|
|
3127
|
+
gl.bindVertexArray(this.quadVAO);
|
|
3128
|
+
const quadBuf = gl.createBuffer();
|
|
3129
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
|
|
3130
|
+
gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW);
|
|
3131
|
+
const qStride = 4 * 4;
|
|
3132
|
+
gl.enableVertexAttribArray(0);
|
|
3133
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, qStride, 0);
|
|
3134
|
+
gl.enableVertexAttribArray(1);
|
|
3135
|
+
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, qStride, 2 * 4);
|
|
3136
|
+
this.instanceData = new Float32Array(MAX_INSTANCES * FLOATS_PER_INSTANCE);
|
|
3137
|
+
this.instanceBuffer = gl.createBuffer();
|
|
3138
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
|
|
3139
|
+
gl.bufferData(gl.ARRAY_BUFFER, this.instanceData.byteLength, gl.DYNAMIC_DRAW);
|
|
3140
|
+
const iStride = FLOATS_PER_INSTANCE * 4;
|
|
3141
|
+
let byteOffset = 0;
|
|
3142
|
+
const addAttr = (loc, size) => {
|
|
3143
|
+
gl.enableVertexAttribArray(loc);
|
|
3144
|
+
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, iStride, byteOffset);
|
|
3145
|
+
gl.vertexAttribDivisor(loc, 1);
|
|
3146
|
+
byteOffset += size * 4;
|
|
3147
|
+
};
|
|
3148
|
+
addAttr(2, 2);
|
|
3149
|
+
addAttr(3, 2);
|
|
3150
|
+
addAttr(4, 1);
|
|
3151
|
+
addAttr(5, 2);
|
|
3152
|
+
addAttr(6, 2);
|
|
3153
|
+
addAttr(7, 1);
|
|
3154
|
+
addAttr(8, 4);
|
|
3155
|
+
addAttr(9, 4);
|
|
3156
|
+
gl.bindVertexArray(null);
|
|
3157
|
+
gl.useProgram(this.program);
|
|
3158
|
+
this.uCamPos = gl.getUniformLocation(this.program, "u_camPos");
|
|
3159
|
+
this.uZoom = gl.getUniformLocation(this.program, "u_zoom");
|
|
3160
|
+
this.uCanvasSize = gl.getUniformLocation(this.program, "u_canvasSize");
|
|
3161
|
+
this.uShake = gl.getUniformLocation(this.program, "u_shake");
|
|
3162
|
+
this.uTexture = gl.getUniformLocation(this.program, "u_texture");
|
|
3163
|
+
this.uUseTexture = gl.getUniformLocation(this.program, "u_useTexture");
|
|
3164
|
+
this.whiteTexture = createWhiteTexture(gl);
|
|
3165
|
+
this.parallaxProgram = createProgram(gl, PARALLAX_VERT_SRC, PARALLAX_FRAG_SRC);
|
|
3166
|
+
const fsVerts = new Float32Array([
|
|
3167
|
+
-1,
|
|
3168
|
+
-1,
|
|
3169
|
+
1,
|
|
3170
|
+
-1,
|
|
3171
|
+
-1,
|
|
3172
|
+
1,
|
|
3173
|
+
1,
|
|
3174
|
+
-1,
|
|
3175
|
+
1,
|
|
3176
|
+
1,
|
|
3177
|
+
-1,
|
|
3178
|
+
1
|
|
3179
|
+
]);
|
|
3180
|
+
this.parallaxVAO = gl.createVertexArray();
|
|
3181
|
+
gl.bindVertexArray(this.parallaxVAO);
|
|
3182
|
+
const fsBuf = gl.createBuffer();
|
|
3183
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, fsBuf);
|
|
3184
|
+
gl.bufferData(gl.ARRAY_BUFFER, fsVerts, gl.STATIC_DRAW);
|
|
3185
|
+
gl.enableVertexAttribArray(0);
|
|
3186
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0);
|
|
3187
|
+
gl.bindVertexArray(null);
|
|
3188
|
+
gl.useProgram(this.parallaxProgram);
|
|
3189
|
+
this.pUTexture = gl.getUniformLocation(this.parallaxProgram, "u_texture");
|
|
3190
|
+
this.pUUvOffset = gl.getUniformLocation(this.parallaxProgram, "u_uvOffset");
|
|
3191
|
+
this.pUTexSize = gl.getUniformLocation(this.parallaxProgram, "u_texSize");
|
|
3192
|
+
this.pUCanvasSize = gl.getUniformLocation(this.parallaxProgram, "u_canvasSize");
|
|
3193
|
+
gl.enable(gl.BLEND);
|
|
3194
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
3195
|
+
}
|
|
3196
|
+
gl;
|
|
3197
|
+
program;
|
|
3198
|
+
quadVAO;
|
|
3199
|
+
instanceBuffer;
|
|
3200
|
+
instanceData;
|
|
3201
|
+
whiteTexture;
|
|
3202
|
+
textures = /* @__PURE__ */ new Map();
|
|
3203
|
+
imageCache = /* @__PURE__ */ new Map();
|
|
3204
|
+
// Cached uniform locations — sprite program
|
|
3205
|
+
uCamPos;
|
|
3206
|
+
uZoom;
|
|
3207
|
+
uCanvasSize;
|
|
3208
|
+
uShake;
|
|
3209
|
+
uTexture;
|
|
3210
|
+
uUseTexture;
|
|
3211
|
+
// ── Parallax program ──────────────────────────────────────────────────────
|
|
3212
|
+
parallaxProgram;
|
|
3213
|
+
parallaxVAO;
|
|
3214
|
+
parallaxTextures = /* @__PURE__ */ new Map();
|
|
3215
|
+
parallaxImageCache = /* @__PURE__ */ new Map();
|
|
3216
|
+
// Cached uniform locations — parallax program
|
|
3217
|
+
pUTexture;
|
|
3218
|
+
pUUvOffset;
|
|
3219
|
+
pUTexSize;
|
|
3220
|
+
pUCanvasSize;
|
|
3221
|
+
// ── Text texture cache ────────────────────────────────────────────────────
|
|
3222
|
+
textureCache = /* @__PURE__ */ new Map();
|
|
3223
|
+
/** Insertion-order key list for LRU-style eviction. */
|
|
3224
|
+
textureCacheKeys = [];
|
|
3225
|
+
// ── Texture management (sprite textures — CLAMP_TO_EDGE) ──────────────────
|
|
3226
|
+
loadTexture(src) {
|
|
3227
|
+
const cached = this.textures.get(src);
|
|
3228
|
+
if (cached) return cached;
|
|
3229
|
+
let img = this.imageCache.get(src);
|
|
3230
|
+
if (!img) {
|
|
3231
|
+
img = new Image();
|
|
3232
|
+
img.src = src;
|
|
3233
|
+
img.onload = () => {
|
|
3234
|
+
const tex = this.gl.createTexture();
|
|
3235
|
+
this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
|
|
3236
|
+
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
|
|
3237
|
+
this.gl.generateMipmap(this.gl.TEXTURE_2D);
|
|
3238
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
|
|
3239
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
|
3240
|
+
this.textures.set(src, tex);
|
|
3241
|
+
};
|
|
3242
|
+
this.imageCache.set(src, img);
|
|
3243
|
+
}
|
|
3244
|
+
return this.whiteTexture;
|
|
3245
|
+
}
|
|
3246
|
+
// ── Parallax texture management (REPEAT wrap mode) ────────────────────────
|
|
3247
|
+
loadParallaxTexture(src) {
|
|
3248
|
+
const cached = this.parallaxTextures.get(src);
|
|
3249
|
+
if (cached) return cached;
|
|
3250
|
+
let img = this.parallaxImageCache.get(src);
|
|
3251
|
+
if (!img) {
|
|
3252
|
+
img = new Image();
|
|
3253
|
+
img.src = src;
|
|
3254
|
+
img.onload = () => {
|
|
3255
|
+
const gl = this.gl;
|
|
3256
|
+
const tex = gl.createTexture();
|
|
3257
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3258
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
3259
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
3260
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
|
3261
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
3262
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
|
3263
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
|
3264
|
+
this.parallaxTextures.set(src, tex);
|
|
3265
|
+
};
|
|
3266
|
+
this.parallaxImageCache.set(src, img);
|
|
3267
|
+
}
|
|
3268
|
+
return null;
|
|
3269
|
+
}
|
|
3270
|
+
// ── Text texture management ───────────────────────────────────────────────
|
|
3271
|
+
getTextTextureKey(text) {
|
|
3272
|
+
return `${text.text}|${text.fontSize ?? 16}|${text.fontFamily ?? "monospace"}|${text.color ?? "#ffffff"}`;
|
|
3273
|
+
}
|
|
3274
|
+
getOrCreateTextTexture(text) {
|
|
3275
|
+
const key = this.getTextTextureKey(text);
|
|
3276
|
+
const cached = this.textureCache.get(key);
|
|
3277
|
+
if (cached) return cached;
|
|
3278
|
+
if (this.textureCache.size >= MAX_TEXT_CACHE) {
|
|
3279
|
+
const oldest = this.textureCacheKeys.shift();
|
|
3280
|
+
if (oldest) {
|
|
3281
|
+
const old = this.textureCache.get(oldest);
|
|
3282
|
+
if (old) this.gl.deleteTexture(old.tex);
|
|
3283
|
+
this.textureCache.delete(oldest);
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
const offscreen = document.createElement("canvas");
|
|
3287
|
+
const ctx2d = offscreen.getContext("2d");
|
|
3288
|
+
const font = `${text.fontSize ?? 16}px ${text.fontFamily ?? "monospace"}`;
|
|
3289
|
+
ctx2d.font = font;
|
|
3290
|
+
const metrics = ctx2d.measureText(text.text);
|
|
3291
|
+
const textW = Math.ceil(metrics.width) + 4;
|
|
3292
|
+
const textH = Math.ceil((text.fontSize ?? 16) * 1.5) + 4;
|
|
3293
|
+
offscreen.width = textW;
|
|
3294
|
+
offscreen.height = textH;
|
|
3295
|
+
ctx2d.font = font;
|
|
3296
|
+
ctx2d.fillStyle = text.color ?? "#ffffff";
|
|
3297
|
+
ctx2d.textAlign = "left";
|
|
3298
|
+
ctx2d.textBaseline = "top";
|
|
3299
|
+
ctx2d.fillText(text.text, 2, 2, text.maxWidth);
|
|
3300
|
+
const gl = this.gl;
|
|
3301
|
+
const tex = gl.createTexture();
|
|
3302
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3303
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, offscreen);
|
|
3304
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
3305
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
|
3306
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
3307
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3308
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3309
|
+
const entry = { tex, w: textW, h: textH };
|
|
3310
|
+
this.textureCache.set(key, entry);
|
|
3311
|
+
this.textureCacheKeys.push(key);
|
|
3312
|
+
return entry;
|
|
3313
|
+
}
|
|
3314
|
+
// ── Instanced draw call ────────────────────────────────────────────────────
|
|
3315
|
+
flush(count, textureKey) {
|
|
3316
|
+
if (count === 0) return;
|
|
3317
|
+
const { gl } = this;
|
|
3318
|
+
const isColor = textureKey.startsWith("__color__");
|
|
3319
|
+
const tex = isColor ? this.whiteTexture : this.loadTexture(textureKey);
|
|
3320
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3321
|
+
gl.uniform1i(this.uUseTexture, isColor ? 0 : 1);
|
|
3322
|
+
gl.bindVertexArray(this.quadVAO);
|
|
3323
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
|
|
3324
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
|
|
3325
|
+
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
|
|
3326
|
+
}
|
|
3327
|
+
flushWithTex(count, tex, useTexture) {
|
|
3328
|
+
if (count === 0) return;
|
|
3329
|
+
const { gl } = this;
|
|
3330
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3331
|
+
gl.uniform1i(this.uUseTexture, useTexture ? 1 : 0);
|
|
3332
|
+
gl.bindVertexArray(this.quadVAO);
|
|
3333
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
|
|
3334
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
|
|
3335
|
+
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
|
|
3336
|
+
}
|
|
3337
|
+
// ── Write one sprite instance into instanceData ───────────────────────────
|
|
3338
|
+
writeInstance(base, x, y, w, h, rot, anchorX, anchorY, offsetX, offsetY, flipX, r, g, b, a, u, v, uw, vh) {
|
|
3339
|
+
const d = this.instanceData;
|
|
3340
|
+
d[base + 0] = x;
|
|
3341
|
+
d[base + 1] = y;
|
|
3342
|
+
d[base + 2] = w;
|
|
3343
|
+
d[base + 3] = h;
|
|
3344
|
+
d[base + 4] = rot;
|
|
3345
|
+
d[base + 5] = anchorX;
|
|
3346
|
+
d[base + 6] = anchorY;
|
|
3347
|
+
d[base + 7] = offsetX;
|
|
3348
|
+
d[base + 8] = offsetY;
|
|
3349
|
+
d[base + 9] = flipX ? 1 : 0;
|
|
3350
|
+
d[base + 10] = r;
|
|
3351
|
+
d[base + 11] = g;
|
|
3352
|
+
d[base + 12] = b;
|
|
3353
|
+
d[base + 13] = a;
|
|
3354
|
+
d[base + 14] = u;
|
|
3355
|
+
d[base + 15] = v;
|
|
3356
|
+
d[base + 16] = uw;
|
|
3357
|
+
d[base + 17] = vh;
|
|
3358
|
+
}
|
|
3359
|
+
// ── Main update loop ───────────────────────────────────────────────────────
|
|
3360
|
+
update(world, dt) {
|
|
3361
|
+
const { gl, canvas } = this;
|
|
3362
|
+
const W = canvas.width;
|
|
3363
|
+
const H = canvas.height;
|
|
3364
|
+
let camX = 0, camY = 0, zoom = 1;
|
|
3365
|
+
let background = "#000000";
|
|
3366
|
+
let shakeX = 0, shakeY = 0;
|
|
3367
|
+
const camId = world.queryOne("Camera2D");
|
|
3368
|
+
if (camId !== void 0) {
|
|
3369
|
+
const cam = world.getComponent(camId, "Camera2D");
|
|
3370
|
+
background = cam.background;
|
|
3371
|
+
if (cam.followEntityId) {
|
|
3372
|
+
const targetId = this.entityIds.get(cam.followEntityId);
|
|
3373
|
+
if (targetId !== void 0) {
|
|
3374
|
+
const t = world.getComponent(targetId, "Transform");
|
|
3375
|
+
if (t) {
|
|
3376
|
+
if (cam.deadZone) {
|
|
3377
|
+
const halfW = cam.deadZone.w / 2;
|
|
3378
|
+
const halfH = cam.deadZone.h / 2;
|
|
3379
|
+
const dx = t.x - cam.x, dy = t.y - cam.y;
|
|
3380
|
+
if (dx > halfW) cam.x = t.x - halfW;
|
|
3381
|
+
else if (dx < -halfW) cam.x = t.x + halfW;
|
|
3382
|
+
if (dy > halfH) cam.y = t.y - halfH;
|
|
3383
|
+
else if (dy < -halfH) cam.y = t.y + halfH;
|
|
3384
|
+
} else if (cam.smoothing > 0) {
|
|
3385
|
+
cam.x += (t.x - cam.x) * (1 - cam.smoothing);
|
|
3386
|
+
cam.y += (t.y - cam.y) * (1 - cam.smoothing);
|
|
3387
|
+
} else {
|
|
3388
|
+
cam.x = t.x;
|
|
3389
|
+
cam.y = t.y;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
if (cam.bounds) {
|
|
3395
|
+
const halfW = W / (2 * cam.zoom);
|
|
3396
|
+
const halfH = H / (2 * cam.zoom);
|
|
3397
|
+
cam.x = Math.max(cam.bounds.x + halfW, Math.min(cam.bounds.x + cam.bounds.width - halfW, cam.x));
|
|
3398
|
+
cam.y = Math.max(cam.bounds.y + halfH, Math.min(cam.bounds.y + cam.bounds.height - halfH, cam.y));
|
|
3399
|
+
}
|
|
3400
|
+
if (cam.shakeTimer > 0) {
|
|
3401
|
+
cam.shakeTimer -= dt;
|
|
3402
|
+
if (cam.shakeTimer < 0) cam.shakeTimer = 0;
|
|
3403
|
+
const progress = cam.shakeDuration > 0 ? cam.shakeTimer / cam.shakeDuration : 0;
|
|
3404
|
+
shakeX = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
|
|
3405
|
+
shakeY = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
|
|
3406
|
+
}
|
|
3407
|
+
camX = cam.x;
|
|
3408
|
+
camY = cam.y;
|
|
3409
|
+
zoom = cam.zoom;
|
|
3410
|
+
}
|
|
3411
|
+
for (const id of world.query("AnimationState", "Sprite")) {
|
|
3412
|
+
const anim = world.getComponent(id, "AnimationState");
|
|
3413
|
+
const sprite = world.getComponent(id, "Sprite");
|
|
3414
|
+
if (!anim.playing || anim.frames.length === 0) continue;
|
|
3415
|
+
anim.timer += dt;
|
|
3416
|
+
const frameDuration = 1 / anim.fps;
|
|
3417
|
+
while (anim.timer >= frameDuration) {
|
|
3418
|
+
anim.timer -= frameDuration;
|
|
3419
|
+
anim.currentIndex++;
|
|
3420
|
+
if (anim.currentIndex >= anim.frames.length) {
|
|
3421
|
+
anim.currentIndex = anim.loop ? 0 : anim.frames.length - 1;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
sprite.frameIndex = anim.frames[anim.currentIndex];
|
|
3425
|
+
}
|
|
3426
|
+
for (const id of world.query("SquashStretch", "RigidBody")) {
|
|
3427
|
+
const ss = world.getComponent(id, "SquashStretch");
|
|
3428
|
+
const rb = world.getComponent(id, "RigidBody");
|
|
3429
|
+
const spd = Math.sqrt(rb.vx * rb.vx + rb.vy * rb.vy);
|
|
3430
|
+
const tScX = rb.vy < -100 ? 1 + ss.intensity * 0.4 : spd > 50 ? 1 - ss.intensity * 0.3 : 1;
|
|
3431
|
+
const tScY = rb.vy < -100 ? 1 - ss.intensity * 0.4 : spd > 50 ? 1 + ss.intensity * 0.3 : 1;
|
|
3432
|
+
ss.currentScaleX += (tScX - ss.currentScaleX) * ss.recovery * dt;
|
|
3433
|
+
ss.currentScaleY += (tScY - ss.currentScaleY) * ss.recovery * dt;
|
|
3434
|
+
}
|
|
3435
|
+
const [br, bg, bb] = parseCSSColor(background);
|
|
3436
|
+
gl.viewport(0, 0, W, H);
|
|
3437
|
+
gl.clearColor(br, bg, bb, 1);
|
|
3438
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
3439
|
+
const parallaxEntities = world.query("ParallaxLayer");
|
|
3440
|
+
if (parallaxEntities.length > 0) {
|
|
3441
|
+
parallaxEntities.sort((a, b) => {
|
|
3442
|
+
const za = world.getComponent(a, "ParallaxLayer").zIndex;
|
|
3443
|
+
const zb = world.getComponent(b, "ParallaxLayer").zIndex;
|
|
3444
|
+
return za - zb;
|
|
3445
|
+
});
|
|
3446
|
+
gl.useProgram(this.parallaxProgram);
|
|
3447
|
+
gl.uniform2f(this.pUCanvasSize, W, H);
|
|
3448
|
+
gl.uniform1i(this.pUTexture, 0);
|
|
3449
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
3450
|
+
gl.bindVertexArray(this.parallaxVAO);
|
|
3451
|
+
for (const id of parallaxEntities) {
|
|
3452
|
+
const layer = world.getComponent(id, "ParallaxLayer");
|
|
3453
|
+
let img = this.parallaxImageCache.get(layer.src);
|
|
3454
|
+
if (!img) {
|
|
3455
|
+
this.loadParallaxTexture(layer.src);
|
|
3456
|
+
continue;
|
|
3457
|
+
}
|
|
3458
|
+
if (!img.complete || img.naturalWidth === 0) continue;
|
|
3459
|
+
if (layer.imageWidth === 0) layer.imageWidth = img.naturalWidth;
|
|
3460
|
+
if (layer.imageHeight === 0) layer.imageHeight = img.naturalHeight;
|
|
3461
|
+
const tex = this.parallaxTextures.get(layer.src);
|
|
3462
|
+
if (!tex) continue;
|
|
3463
|
+
const imgW = layer.imageWidth;
|
|
3464
|
+
const imgH = layer.imageHeight;
|
|
3465
|
+
const drawX = layer.offsetX - camX * layer.speedX;
|
|
3466
|
+
const drawY = layer.offsetY - camY * layer.speedY;
|
|
3467
|
+
const uvOffsetX = (drawX / imgW % 1 + 1) % 1;
|
|
3468
|
+
const uvOffsetY = (drawY / imgH % 1 + 1) % 1;
|
|
3469
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3470
|
+
gl.uniform2f(this.pUUvOffset, uvOffsetX, uvOffsetY);
|
|
3471
|
+
gl.uniform2f(this.pUTexSize, imgW, imgH);
|
|
3472
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
gl.useProgram(this.program);
|
|
3476
|
+
gl.uniform2f(this.uCamPos, camX, camY);
|
|
3477
|
+
gl.uniform1f(this.uZoom, zoom);
|
|
3478
|
+
gl.uniform2f(this.uCanvasSize, W, H);
|
|
3479
|
+
gl.uniform2f(this.uShake, shakeX, shakeY);
|
|
3480
|
+
gl.uniform1i(this.uTexture, 0);
|
|
3481
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
3482
|
+
const renderables = world.query("Transform", "Sprite");
|
|
3483
|
+
renderables.sort((a, b) => {
|
|
3484
|
+
const sa = world.getComponent(a, "Sprite");
|
|
3485
|
+
const sb = world.getComponent(b, "Sprite");
|
|
3486
|
+
const zd = sa.zIndex - sb.zIndex;
|
|
3487
|
+
if (zd !== 0) return zd;
|
|
3488
|
+
const ka = getTextureKey(sa), kb = getTextureKey(sb);
|
|
3489
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
3490
|
+
});
|
|
3491
|
+
let batchCount = 0;
|
|
3492
|
+
let batchKey = "";
|
|
3493
|
+
for (let i = 0; i <= renderables.length; i++) {
|
|
3494
|
+
if (i === renderables.length) {
|
|
3495
|
+
this.flush(batchCount, batchKey);
|
|
3496
|
+
break;
|
|
3497
|
+
}
|
|
3498
|
+
const id = renderables[i];
|
|
3499
|
+
const transform = world.getComponent(id, "Transform");
|
|
3500
|
+
const sprite = world.getComponent(id, "Sprite");
|
|
3501
|
+
if (!sprite.visible) continue;
|
|
3502
|
+
if (sprite.src && !sprite.image) {
|
|
3503
|
+
let img = this.imageCache.get(sprite.src);
|
|
3504
|
+
if (!img) {
|
|
3505
|
+
img = new Image();
|
|
3506
|
+
img.src = sprite.src;
|
|
3507
|
+
this.imageCache.set(sprite.src, img);
|
|
3508
|
+
img.onload = () => {
|
|
3509
|
+
const tex = this.gl.createTexture();
|
|
3510
|
+
this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
|
|
3511
|
+
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
|
|
3512
|
+
this.gl.generateMipmap(this.gl.TEXTURE_2D);
|
|
3513
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
|
|
3514
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
|
3515
|
+
this.textures.set(sprite.src, tex);
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
sprite.image = img;
|
|
3519
|
+
}
|
|
3520
|
+
const key = getTextureKey(sprite);
|
|
3521
|
+
if (key !== batchKey && batchCount > 0 || batchCount >= MAX_INSTANCES) {
|
|
3522
|
+
this.flush(batchCount, batchKey);
|
|
3523
|
+
batchCount = 0;
|
|
3524
|
+
}
|
|
3525
|
+
batchKey = key;
|
|
3526
|
+
const ss = world.getComponent(id, "SquashStretch");
|
|
3527
|
+
const scaleXMod = ss ? ss.currentScaleX : 1;
|
|
3528
|
+
const scaleYMod = ss ? ss.currentScaleY : 1;
|
|
3529
|
+
const [r, g, b, a] = parseCSSColor(sprite.color);
|
|
3530
|
+
const uv = getUVRect(sprite);
|
|
3531
|
+
this.writeInstance(
|
|
3532
|
+
batchCount * FLOATS_PER_INSTANCE,
|
|
3533
|
+
transform.x,
|
|
3534
|
+
transform.y,
|
|
3535
|
+
sprite.width * transform.scaleX * scaleXMod,
|
|
3536
|
+
sprite.height * transform.scaleY * scaleYMod,
|
|
3537
|
+
transform.rotation,
|
|
3538
|
+
sprite.anchorX,
|
|
3539
|
+
sprite.anchorY,
|
|
3540
|
+
sprite.offsetX,
|
|
3541
|
+
sprite.offsetY,
|
|
3542
|
+
sprite.flipX,
|
|
3543
|
+
r,
|
|
3544
|
+
g,
|
|
3545
|
+
b,
|
|
3546
|
+
a,
|
|
3547
|
+
uv[0],
|
|
3548
|
+
uv[1],
|
|
3549
|
+
uv[2],
|
|
3550
|
+
uv[3]
|
|
3551
|
+
);
|
|
3552
|
+
batchCount++;
|
|
3553
|
+
}
|
|
3554
|
+
const textEntities = world.query("Transform", "Text");
|
|
3555
|
+
textEntities.sort((a, b) => {
|
|
3556
|
+
const ta = world.getComponent(a, "Text");
|
|
3557
|
+
const tb = world.getComponent(b, "Text");
|
|
3558
|
+
return ta.zIndex - tb.zIndex;
|
|
3559
|
+
});
|
|
3560
|
+
for (const id of textEntities) {
|
|
3561
|
+
const transform = world.getComponent(id, "Transform");
|
|
3562
|
+
const text = world.getComponent(id, "Text");
|
|
3563
|
+
if (!text.visible) continue;
|
|
3564
|
+
const entry = this.getOrCreateTextTexture(text);
|
|
3565
|
+
if (!entry) continue;
|
|
3566
|
+
this.flush(batchCount, batchKey);
|
|
3567
|
+
batchCount = 0;
|
|
3568
|
+
batchKey = "";
|
|
3569
|
+
this.writeInstance(
|
|
3570
|
+
0,
|
|
3571
|
+
transform.x + text.offsetX,
|
|
3572
|
+
transform.y + text.offsetY,
|
|
3573
|
+
entry.w,
|
|
3574
|
+
entry.h,
|
|
3575
|
+
transform.rotation,
|
|
3576
|
+
0,
|
|
3577
|
+
0,
|
|
3578
|
+
// anchor top-left
|
|
3579
|
+
0,
|
|
3580
|
+
0,
|
|
3581
|
+
false,
|
|
3582
|
+
1,
|
|
3583
|
+
1,
|
|
3584
|
+
1,
|
|
3585
|
+
1,
|
|
3586
|
+
// white tint — color baked into texture
|
|
3587
|
+
0,
|
|
3588
|
+
0,
|
|
3589
|
+
1,
|
|
3590
|
+
1
|
|
3591
|
+
);
|
|
3592
|
+
this.flushWithTex(1, entry.tex, true);
|
|
3593
|
+
}
|
|
3594
|
+
for (const id of world.query("Transform", "ParticlePool")) {
|
|
3595
|
+
const t = world.getComponent(id, "Transform");
|
|
3596
|
+
const pool = world.getComponent(id, "ParticlePool");
|
|
3597
|
+
pool.particles = pool.particles.filter((p) => {
|
|
3598
|
+
p.life -= dt;
|
|
3599
|
+
p.x += p.vx * dt;
|
|
3600
|
+
p.y += p.vy * dt;
|
|
3601
|
+
p.vy += p.gravity * dt;
|
|
3602
|
+
return p.life > 0;
|
|
3603
|
+
});
|
|
3604
|
+
if (pool.active && pool.particles.length < pool.maxParticles) {
|
|
3605
|
+
pool.timer += dt;
|
|
3606
|
+
const spawnCount = Math.floor(pool.timer * pool.rate);
|
|
3607
|
+
pool.timer -= spawnCount / pool.rate;
|
|
3608
|
+
for (let i = 0; i < spawnCount && pool.particles.length < pool.maxParticles; i++) {
|
|
3609
|
+
const angle = pool.angle + (world.rng() - 0.5) * pool.spread;
|
|
3610
|
+
const speed = pool.speed * (0.5 + world.rng() * 0.5);
|
|
3611
|
+
pool.particles.push({
|
|
3612
|
+
x: t.x,
|
|
3613
|
+
y: t.y,
|
|
3614
|
+
vx: Math.cos(angle) * speed,
|
|
3615
|
+
vy: Math.sin(angle) * speed,
|
|
3616
|
+
life: pool.particleLife,
|
|
3617
|
+
maxLife: pool.particleLife,
|
|
3618
|
+
size: pool.particleSize,
|
|
3619
|
+
color: pool.color,
|
|
3620
|
+
gravity: pool.gravity
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
let pCount = 0;
|
|
3625
|
+
const pKey = `__color__`;
|
|
3626
|
+
for (const p of pool.particles) {
|
|
3627
|
+
if (pCount >= MAX_INSTANCES) {
|
|
3628
|
+
this.flush(pCount, pKey);
|
|
3629
|
+
pCount = 0;
|
|
3630
|
+
}
|
|
3631
|
+
const alpha = p.life / p.maxLife;
|
|
3632
|
+
const [r, g, b] = parseCSSColor(p.color);
|
|
3633
|
+
this.writeInstance(
|
|
3634
|
+
pCount * FLOATS_PER_INSTANCE,
|
|
3635
|
+
p.x,
|
|
3636
|
+
p.y,
|
|
3637
|
+
p.size,
|
|
3638
|
+
p.size,
|
|
3639
|
+
0,
|
|
3640
|
+
0.5,
|
|
3641
|
+
0.5,
|
|
3642
|
+
0,
|
|
3643
|
+
0,
|
|
3644
|
+
false,
|
|
3645
|
+
r,
|
|
3646
|
+
g,
|
|
3647
|
+
b,
|
|
3648
|
+
alpha,
|
|
3649
|
+
0,
|
|
3650
|
+
0,
|
|
3651
|
+
1,
|
|
3652
|
+
1
|
|
3653
|
+
);
|
|
3654
|
+
pCount++;
|
|
3655
|
+
}
|
|
3656
|
+
if (pCount > 0) this.flush(pCount, pKey);
|
|
3657
|
+
}
|
|
3658
|
+
for (const id of world.query("Transform", "Trail")) {
|
|
3659
|
+
const t = world.getComponent(id, "Transform");
|
|
3660
|
+
const trail = world.getComponent(id, "Trail");
|
|
3661
|
+
trail.points.unshift({ x: t.x, y: t.y });
|
|
3662
|
+
if (trail.points.length > trail.length) trail.points.length = trail.length;
|
|
3663
|
+
if (trail.points.length < 1) continue;
|
|
3664
|
+
const [tr, tg, tb] = parseCSSColor(trail.color);
|
|
3665
|
+
const trailW = trail.width > 0 ? trail.width : 1;
|
|
3666
|
+
let tCount = 0;
|
|
3667
|
+
for (let i = 0; i < trail.points.length; i++) {
|
|
3668
|
+
if (tCount >= MAX_INSTANCES) {
|
|
3669
|
+
this.flush(tCount, "__color__");
|
|
3670
|
+
tCount = 0;
|
|
3671
|
+
}
|
|
3672
|
+
const alpha = 1 - i / trail.points.length;
|
|
3673
|
+
this.writeInstance(
|
|
3674
|
+
tCount * FLOATS_PER_INSTANCE,
|
|
3675
|
+
trail.points[i].x,
|
|
3676
|
+
trail.points[i].y,
|
|
3677
|
+
trailW,
|
|
3678
|
+
trailW,
|
|
3679
|
+
0,
|
|
3680
|
+
0.5,
|
|
3681
|
+
0.5,
|
|
3682
|
+
0,
|
|
3683
|
+
0,
|
|
3684
|
+
false,
|
|
3685
|
+
tr,
|
|
3686
|
+
tg,
|
|
3687
|
+
tb,
|
|
3688
|
+
alpha,
|
|
3689
|
+
0,
|
|
3690
|
+
0,
|
|
3691
|
+
1,
|
|
3692
|
+
1
|
|
3693
|
+
);
|
|
3694
|
+
tCount++;
|
|
3695
|
+
}
|
|
3696
|
+
if (tCount > 0) this.flush(tCount, "__color__");
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
};
|
|
3700
|
+
|
|
2890
3701
|
// src/components/Game.tsx
|
|
2891
3702
|
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
2892
3703
|
function timedSystem(name, system, timings) {
|
|
@@ -2916,6 +3727,7 @@ function Game({
|
|
|
2916
3727
|
children
|
|
2917
3728
|
}) {
|
|
2918
3729
|
const canvasRef = useRef3(null);
|
|
3730
|
+
const debugCanvasRef = useRef3(null);
|
|
2919
3731
|
const wrapperRef = useRef3(null);
|
|
2920
3732
|
const [engine, setEngine] = useState2(null);
|
|
2921
3733
|
const [assetsReady, setAssetsReady] = useState2(asyncAssets);
|
|
@@ -2932,14 +3744,34 @@ function Game({
|
|
|
2932
3744
|
let canvas2d;
|
|
2933
3745
|
let builtinRenderSystem;
|
|
2934
3746
|
let renderSystem;
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
} else {
|
|
3747
|
+
let activeRenderSystem;
|
|
3748
|
+
if (CustomRenderer === "canvas2d") {
|
|
2938
3749
|
canvas2d = new Canvas2DRenderer(canvas);
|
|
2939
3750
|
builtinRenderSystem = new RenderSystem(canvas2d, entityIds);
|
|
2940
3751
|
renderSystem = builtinRenderSystem;
|
|
3752
|
+
} else if (CustomRenderer) {
|
|
3753
|
+
renderSystem = new CustomRenderer(canvas, entityIds);
|
|
3754
|
+
} else {
|
|
3755
|
+
try {
|
|
3756
|
+
renderSystem = new WebGLRenderSystem(canvas, entityIds);
|
|
3757
|
+
} catch (e) {
|
|
3758
|
+
console.warn("[Cubeforge] WebGL2 unavailable, falling back to Canvas2D:", e);
|
|
3759
|
+
canvas2d = new Canvas2DRenderer(canvas);
|
|
3760
|
+
builtinRenderSystem = new RenderSystem(canvas2d, entityIds);
|
|
3761
|
+
renderSystem = builtinRenderSystem;
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
activeRenderSystem = renderSystem;
|
|
3765
|
+
let debugSystem = null;
|
|
3766
|
+
if (debug) {
|
|
3767
|
+
const debugCanvas2dEl = debugCanvasRef.current;
|
|
3768
|
+
if (debugCanvas2dEl) {
|
|
3769
|
+
const debugCanvas2d = new Canvas2DRenderer(debugCanvas2dEl);
|
|
3770
|
+
debugSystem = new DebugSystem(debugCanvas2d);
|
|
3771
|
+
} else if (canvas2d) {
|
|
3772
|
+
debugSystem = new DebugSystem(canvas2d);
|
|
3773
|
+
}
|
|
2941
3774
|
}
|
|
2942
|
-
const debugSystem = debug && canvas2d ? new DebugSystem(canvas2d) : null;
|
|
2943
3775
|
const systemTimings = /* @__PURE__ */ new Map();
|
|
2944
3776
|
ecs.addSystem(timedSystem("ScriptSystem", new ScriptSystem(input), systemTimings));
|
|
2945
3777
|
ecs.addSystem(timedSystem("PhysicsSystem", physics, systemTimings));
|
|
@@ -2960,7 +3792,20 @@ function Game({
|
|
|
2960
3792
|
handle.onFrame?.();
|
|
2961
3793
|
}
|
|
2962
3794
|
});
|
|
2963
|
-
const state = {
|
|
3795
|
+
const state = {
|
|
3796
|
+
ecs,
|
|
3797
|
+
input,
|
|
3798
|
+
renderer: canvas2d,
|
|
3799
|
+
renderSystem: builtinRenderSystem,
|
|
3800
|
+
activeRenderSystem,
|
|
3801
|
+
physics,
|
|
3802
|
+
events,
|
|
3803
|
+
assets,
|
|
3804
|
+
loop,
|
|
3805
|
+
canvas,
|
|
3806
|
+
entityIds,
|
|
3807
|
+
systemTimings
|
|
3808
|
+
};
|
|
2964
3809
|
setEngine(state);
|
|
2965
3810
|
if (plugins) {
|
|
2966
3811
|
const pluginNames = new Set(plugins.map((p) => p.name));
|
|
@@ -2999,6 +3844,11 @@ function Game({
|
|
|
2999
3844
|
const s2 = Math.min(scaleX, scaleY);
|
|
3000
3845
|
canvas.style.transform = `scale(${s2})`;
|
|
3001
3846
|
canvas.style.transformOrigin = "top left";
|
|
3847
|
+
const debugEl = debugCanvasRef.current;
|
|
3848
|
+
if (debugEl) {
|
|
3849
|
+
debugEl.style.transform = `scale(${s2})`;
|
|
3850
|
+
debugEl.style.transformOrigin = "top left";
|
|
3851
|
+
}
|
|
3002
3852
|
};
|
|
3003
3853
|
updateScale();
|
|
3004
3854
|
resizeObserver = new ResizeObserver(updateScale);
|
|
@@ -3060,6 +3910,15 @@ function Game({
|
|
|
3060
3910
|
className
|
|
3061
3911
|
}
|
|
3062
3912
|
),
|
|
3913
|
+
debug && /* @__PURE__ */ jsx2(
|
|
3914
|
+
"canvas",
|
|
3915
|
+
{
|
|
3916
|
+
ref: debugCanvasRef,
|
|
3917
|
+
width,
|
|
3918
|
+
height,
|
|
3919
|
+
style: { position: "absolute", top: 0, left: 0, pointerEvents: "none" }
|
|
3920
|
+
}
|
|
3921
|
+
),
|
|
3063
3922
|
!assetsReady && /* @__PURE__ */ jsxs2("div", { style: {
|
|
3064
3923
|
position: "absolute",
|
|
3065
3924
|
inset: 0,
|
|
@@ -4313,7 +5172,13 @@ function useCamera() {
|
|
|
4313
5172
|
const engine = useGame();
|
|
4314
5173
|
return useMemo(() => ({
|
|
4315
5174
|
shake(intensity, duration) {
|
|
4316
|
-
engine.
|
|
5175
|
+
const cams = engine.ecs.query("Camera2D");
|
|
5176
|
+
if (cams.length === 0) return;
|
|
5177
|
+
const cam = engine.ecs.getComponent(cams[0], "Camera2D");
|
|
5178
|
+
if (!cam) return;
|
|
5179
|
+
cam.shakeIntensity = intensity;
|
|
5180
|
+
cam.shakeDuration = duration;
|
|
5181
|
+
cam.shakeTimer = duration;
|
|
4317
5182
|
},
|
|
4318
5183
|
setFollowOffset(x, y) {
|
|
4319
5184
|
const camId = engine.ecs.queryOne("Camera2D");
|
|
@@ -5224,6 +6089,7 @@ export {
|
|
|
5224
6089
|
BoxCollider,
|
|
5225
6090
|
Camera2D,
|
|
5226
6091
|
CameraZone,
|
|
6092
|
+
RenderSystem as Canvas2DRenderSystem,
|
|
5227
6093
|
CapsuleCollider,
|
|
5228
6094
|
Checkpoint,
|
|
5229
6095
|
CircleCollider,
|
|
@@ -5243,6 +6109,7 @@ export {
|
|
|
5243
6109
|
Trail,
|
|
5244
6110
|
Transform,
|
|
5245
6111
|
VirtualJoystick,
|
|
6112
|
+
WebGLRenderSystem,
|
|
5246
6113
|
World,
|
|
5247
6114
|
arrive,
|
|
5248
6115
|
createAtlas,
|