punter.js 1.0.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/.nvmrc +1 -0
- package/LICENSE.MD +21 -0
- package/README.MD +731 -0
- package/games/pong.html +260 -0
- package/images/pong/sprites/ball.png +0 -0
- package/images/pong/sprites/paddle.png +0 -0
- package/images/punterjs.png +0 -0
- package/package.json +33 -0
- package/punter.js +1514 -0
- package/tests/fixtures/index.html +30 -0
- package/tests/fixtures/silence.wav +0 -0
- package/tests/input-spec.js +104 -0
- package/tests/interface-spec.js +200 -0
- package/tests/jasmine/config.json +20 -0
- package/tests/jasmine/reporter.js +25 -0
- package/tests/jasmine/teardown.js +7 -0
- package/tests/jasmine/timeout.js +1 -0
- package/tests/scenes-spec.js +87 -0
- package/tests/setup.js +49 -0
- package/tests/sounds-spec.js +83 -0
- package/tests/sprites-spec.js +259 -0
package/games/pong.html
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Pong</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body { width: 100%; height: 100%; background: #1e2230; overflow: hidden; }
|
|
10
|
+
canvas { display: block; }
|
|
11
|
+
</style>
|
|
12
|
+
<script src="../punter.js"></script>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<canvas id="game"></canvas>
|
|
16
|
+
<script>
|
|
17
|
+
|
|
18
|
+
// =========================================================
|
|
19
|
+
// COLORS (Orca Scan brand palette)
|
|
20
|
+
// =========================================================
|
|
21
|
+
var COLOR_BG = '#1b2125';
|
|
22
|
+
var COLOR_NET = '#2c3033';
|
|
23
|
+
var COLOR_SCORE = '#f5c518';
|
|
24
|
+
|
|
25
|
+
// =========================================================
|
|
26
|
+
// HELPERS
|
|
27
|
+
// =========================================================
|
|
28
|
+
|
|
29
|
+
var DPR = punter.dpr;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Converts a design-pixel value to real screen pixels.
|
|
33
|
+
* On a retina display (DPR=2), px(14) becomes 28 — keeping sizes
|
|
34
|
+
* physically consistent across all screens.
|
|
35
|
+
* @param {number} n - value in design pixels
|
|
36
|
+
* @returns {number}
|
|
37
|
+
*/
|
|
38
|
+
function px(n) {
|
|
39
|
+
return n * DPR;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keeps a number between a minimum and maximum value.
|
|
44
|
+
* e.g. clamp(110, 0, 100) returns 100.
|
|
45
|
+
* @param {number} value
|
|
46
|
+
* @param {number} min
|
|
47
|
+
* @param {number} max
|
|
48
|
+
* @returns {number}
|
|
49
|
+
*/
|
|
50
|
+
function clamp(value, min, max) {
|
|
51
|
+
return Math.max(min, Math.min(value, max));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =========================================================
|
|
55
|
+
// GAME SETTINGS
|
|
56
|
+
// =========================================================
|
|
57
|
+
var MARGIN = px(40); // gap between paddle and screen edge
|
|
58
|
+
var PADDLE_W = px(14);
|
|
59
|
+
var PADDLE_H = px(90);
|
|
60
|
+
var BALL_SIZE = px(14);
|
|
61
|
+
var PADDLE_SPEED = px(7);
|
|
62
|
+
var AI_SPEED = px(5);
|
|
63
|
+
var BALL_SPEED = px(5);
|
|
64
|
+
var MAX_BALL_SPEED = px(14);
|
|
65
|
+
|
|
66
|
+
// Named serve directions — used when resetting the ball after a point
|
|
67
|
+
var SERVE_LEFT = -1; // ball moves left (toward the AI)
|
|
68
|
+
var SERVE_RIGHT = 1; // ball moves right (toward the player)
|
|
69
|
+
|
|
70
|
+
// =========================================================
|
|
71
|
+
// GAME STATE
|
|
72
|
+
// =========================================================
|
|
73
|
+
var ballVel = { x: BALL_SPEED, y: BALL_SPEED * 0.6 }; // ball direction + speed
|
|
74
|
+
var score = { player: 0, ai: 0 };
|
|
75
|
+
|
|
76
|
+
// Sprite references — set when the scene starts
|
|
77
|
+
var playerSprite, aiSprite, ballSprite;
|
|
78
|
+
|
|
79
|
+
// =========================================================
|
|
80
|
+
// SETUP
|
|
81
|
+
// Load the two sprite images before the game starts.
|
|
82
|
+
// =========================================================
|
|
83
|
+
punter.init({
|
|
84
|
+
canvas: '#game',
|
|
85
|
+
sprites: {
|
|
86
|
+
paddle: '../images/pong/sprites/paddle.png',
|
|
87
|
+
ball: '../images/pong/sprites/ball.png'
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// =========================================================
|
|
92
|
+
// SCENE: play
|
|
93
|
+
// =========================================================
|
|
94
|
+
punter.scene('play', function () {
|
|
95
|
+
|
|
96
|
+
// Create the three sprites punter.js will draw each frame.
|
|
97
|
+
// w/h set the draw size; the image is scaled to fit automatically.
|
|
98
|
+
playerSprite = punter.createSprite({ id: 'player', key: 'paddle', x: 0, y: 0, w: PADDLE_W, h: PADDLE_H });
|
|
99
|
+
aiSprite = punter.createSprite({ id: 'ai', key: 'paddle', x: 0, y: 0, w: PADDLE_W, h: PADDLE_H });
|
|
100
|
+
ballSprite = punter.createSprite({ id: 'ball', key: 'ball', x: 0, y: 0, w: BALL_SIZE, h: BALL_SIZE });
|
|
101
|
+
|
|
102
|
+
// Place everything in the correct starting position
|
|
103
|
+
resetPositions();
|
|
104
|
+
|
|
105
|
+
// -------------------------------------------------------
|
|
106
|
+
// UPDATE — game logic, runs ~60 times per second
|
|
107
|
+
// -------------------------------------------------------
|
|
108
|
+
punter.on('update', function () {
|
|
109
|
+
var w = punter.width;
|
|
110
|
+
var h = punter.height;
|
|
111
|
+
|
|
112
|
+
// Keep AI paddle pinned to the right edge (handles resize too)
|
|
113
|
+
aiSprite.x = w - MARGIN - PADDLE_W;
|
|
114
|
+
|
|
115
|
+
// --- Player: keyboard ---
|
|
116
|
+
if (punter.keys['ArrowUp'] || punter.keys['w']) playerSprite.y -= PADDLE_SPEED;
|
|
117
|
+
if (punter.keys['ArrowDown'] || punter.keys['s']) playerSprite.y += PADDLE_SPEED;
|
|
118
|
+
|
|
119
|
+
// --- Player: tap / click ---
|
|
120
|
+
if (punter.mouse.clicked) {
|
|
121
|
+
playerSprite.y = punter.mouse.y - PADDLE_H / 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Keep the player paddle inside the screen
|
|
125
|
+
playerSprite.y = clamp(playerSprite.y, 0, h - PADDLE_H);
|
|
126
|
+
|
|
127
|
+
// --- AI: follow the ball ---
|
|
128
|
+
var aiCenterY = aiSprite.y + PADDLE_H / 2;
|
|
129
|
+
if (ballSprite.y < aiCenterY - 10) aiSprite.y -= AI_SPEED;
|
|
130
|
+
if (ballSprite.y > aiCenterY + 10) aiSprite.y += AI_SPEED;
|
|
131
|
+
aiSprite.y = clamp(aiSprite.y, 0, h - PADDLE_H);
|
|
132
|
+
|
|
133
|
+
// --- Move the ball ---
|
|
134
|
+
ballSprite.x += ballVel.x;
|
|
135
|
+
ballSprite.y += ballVel.y;
|
|
136
|
+
|
|
137
|
+
// Bounce off top and bottom walls
|
|
138
|
+
if (ballSprite.y <= 0) { ballVel.y = Math.abs(ballVel.y); ballSprite.y = 0; }
|
|
139
|
+
if (ballSprite.y >= h - BALL_SIZE) { ballVel.y = -Math.abs(ballVel.y); ballSprite.y = h - BALL_SIZE; }
|
|
140
|
+
|
|
141
|
+
// --- Collision: ball hits player paddle ---
|
|
142
|
+
if (ballVel.x < 0 && ballSprite.isCollidingWith(playerSprite)) {
|
|
143
|
+
|
|
144
|
+
ballVel.x = Math.abs(ballVel.x) * 1.05;
|
|
145
|
+
ballSprite.x = playerSprite.x + PADDLE_W;
|
|
146
|
+
// Where on the paddle the ball hit: 0 = top edge, 1 = bottom edge, 0.5 = centre (flat return)
|
|
147
|
+
var playerHit = (ballSprite.y + BALL_SIZE / 2 - playerSprite.y) / PADDLE_H;
|
|
148
|
+
ballVel.y = (playerHit - 0.5) * BALL_SPEED * 2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Collision: ball hits AI paddle ---
|
|
152
|
+
if (ballVel.x > 0 && ballSprite.isCollidingWith(aiSprite)) {
|
|
153
|
+
|
|
154
|
+
ballVel.x = -Math.abs(ballVel.x) * 1.05;
|
|
155
|
+
ballSprite.x = aiSprite.x - BALL_SIZE;
|
|
156
|
+
// Where on the paddle the ball hit: 0 = top edge, 1 = bottom edge, 0.5 = centre (flat return)
|
|
157
|
+
var aiHit = (ballSprite.y + BALL_SIZE / 2 - aiSprite.y) / PADDLE_H;
|
|
158
|
+
ballVel.y = (aiHit - 0.5) * BALL_SPEED * 2;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Cap ball speed so the game stays playable
|
|
162
|
+
if (ballVel.x > MAX_BALL_SPEED) ballVel.x = MAX_BALL_SPEED;
|
|
163
|
+
if (ballVel.x < -MAX_BALL_SPEED) ballVel.x = -MAX_BALL_SPEED;
|
|
164
|
+
|
|
165
|
+
// --- Scoring ---
|
|
166
|
+
if (ballSprite.x + BALL_SIZE < 0) { score.ai++; resetBall(SERVE_RIGHT); }
|
|
167
|
+
if (ballSprite.x > w) { score.player++; resetBall(SERVE_LEFT); }
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// -------------------------------------------------------
|
|
171
|
+
// DRAW — render each frame
|
|
172
|
+
// -------------------------------------------------------
|
|
173
|
+
punter.on('draw', function () {
|
|
174
|
+
var ctx = this; // punter passes the canvas 2D context as 'this'
|
|
175
|
+
var w = punter.width;
|
|
176
|
+
var h = punter.height;
|
|
177
|
+
|
|
178
|
+
// Background
|
|
179
|
+
ctx.fillStyle = COLOR_BG;
|
|
180
|
+
ctx.fillRect(0, 0, w, h);
|
|
181
|
+
|
|
182
|
+
// Dashed centre line
|
|
183
|
+
ctx.setLineDash([px(8), px(10)]);
|
|
184
|
+
ctx.strokeStyle = COLOR_NET;
|
|
185
|
+
ctx.lineWidth = px(2);
|
|
186
|
+
ctx.beginPath();
|
|
187
|
+
ctx.moveTo(w / 2, 0);
|
|
188
|
+
ctx.lineTo(w / 2, h);
|
|
189
|
+
ctx.stroke();
|
|
190
|
+
ctx.setLineDash([]);
|
|
191
|
+
|
|
192
|
+
// Sprites — punter.js scales and draws the PNG images
|
|
193
|
+
playerSprite.draw(ctx);
|
|
194
|
+
aiSprite.draw(ctx);
|
|
195
|
+
ballSprite.draw(ctx);
|
|
196
|
+
|
|
197
|
+
// Scores
|
|
198
|
+
ctx.fillStyle = COLOR_SCORE;
|
|
199
|
+
ctx.font = 'bold ' + px(48) + 'px monospace';
|
|
200
|
+
ctx.textAlign = 'center';
|
|
201
|
+
ctx.textBaseline = 'top';
|
|
202
|
+
ctx.fillText(score.player, w / 2 - px(70), px(20));
|
|
203
|
+
ctx.fillText(score.ai, w / 2 + px(70), px(20));
|
|
204
|
+
|
|
205
|
+
// Controls hint
|
|
206
|
+
ctx.fillStyle = COLOR_NET;
|
|
207
|
+
ctx.font = px(14) + 'px monospace';
|
|
208
|
+
ctx.textBaseline = 'bottom';
|
|
209
|
+
ctx.fillText('Arrow keys or W / S · tap to move', w / 2, h - px(12));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Centres both paddles and resets the ball.
|
|
215
|
+
* Called when the scene starts and on every window resize.
|
|
216
|
+
*/
|
|
217
|
+
function resetPositions() {
|
|
218
|
+
// Sprites aren't created until the ready event fires and the scene runs.
|
|
219
|
+
// The engine's resize can fire before that, so bail out early if not ready.
|
|
220
|
+
if (!playerSprite || !aiSprite || !ballSprite) return;
|
|
221
|
+
|
|
222
|
+
var w = punter.width;
|
|
223
|
+
var h = punter.height;
|
|
224
|
+
|
|
225
|
+
playerSprite.x = MARGIN;
|
|
226
|
+
playerSprite.y = h / 2 - PADDLE_H / 2;
|
|
227
|
+
|
|
228
|
+
aiSprite.x = w - MARGIN - PADDLE_W;
|
|
229
|
+
aiSprite.y = h / 2 - PADDLE_H / 2;
|
|
230
|
+
|
|
231
|
+
resetBall(SERVE_LEFT);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Puts the ball back in the centre with a fresh serve.
|
|
236
|
+
* @param {number} direction - SERVE_LEFT (toward AI) or SERVE_RIGHT (toward player)
|
|
237
|
+
*/
|
|
238
|
+
function resetBall(direction) {
|
|
239
|
+
ballSprite.x = punter.width / 2 - BALL_SIZE / 2;
|
|
240
|
+
ballSprite.y = punter.height / 2 - BALL_SIZE / 2;
|
|
241
|
+
ballVel.x = BALL_SPEED * direction;
|
|
242
|
+
ballVel.y = (Math.random() - 0.5) * BALL_SPEED * 1.2;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Re-centre everything when the browser window resizes
|
|
246
|
+
punter.on('resize', function () {
|
|
247
|
+
resetPositions();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// =========================================================
|
|
251
|
+
// START — go to the play scene once everything is loaded
|
|
252
|
+
// =========================================================
|
|
253
|
+
punter.on('ready', function () {
|
|
254
|
+
score = { player: 0, ai: 0 };
|
|
255
|
+
punter.go('play');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
</script>
|
|
259
|
+
</body>
|
|
260
|
+
</html>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "punter.js",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight, dependency-free JavaScript 2D game engine for the browser. Drop in a script tag and start building.",
|
|
5
|
+
"main": "punter.js",
|
|
6
|
+
"directories": {
|
|
7
|
+
"test": "tests"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "npx serve .",
|
|
11
|
+
"test": ". \"$NVM_DIR/nvm.sh\" && nvm use && NODE_ENV=test TZ=UTC node_modules/.bin/jasmine --config=tests/jasmine/config.json"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/orca-scan/punter.js.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/orca-scan/punter.js/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/orca-scan/punter.js#readme",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"jasmine": "^5.13.0",
|
|
29
|
+
"jasmine-console-reporter": "^3.1.0",
|
|
30
|
+
"jasmine-xml-reporter": "^1.2.1",
|
|
31
|
+
"puppeteer": "^25.3.0"
|
|
32
|
+
}
|
|
33
|
+
}
|