forked from KilledByAPixel/LittleJS-AI
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpool.html
More file actions
1120 lines (1006 loc) · 42.9 KB
/
Copy pathpool.html
File metadata and controls
1120 lines (1006 loc) · 42.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html><head>
<title>Pool</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head><body style="background:#000">
<script src="../dist/littlejs.js?1.18.15"></script>
<script src="../templates/soundGenerator.js"></script>
<script src="../templates/textureGenerator.js"></script>
<script src="../dist/box2d.wasm.js?1.18.0"></script>
<script src="../templates/menus.js"></script>
<script>
'use strict';
// engine settings
debugWatermark = false;
showEngineVersion = false;
paused = false;
gravity = vec2(0, 0);
cameraPos = vec2(0, 0);
canvasMaxSize = vec2(2048, 2048);
tilesPixelated = false;
///////////////////////////////////////////////////////////////////////////////
// pool (box2d)
// table layout (world units)
const TABLE_SIZE = vec2(34, 17); // inner felt
const RAIL_THICK = 2.2; // wood rail outside felt
const WALL_THICK = 3; // collider thickness (overlaps rail visually)
const BALL_DIAM = 1;
const RACK_SPACING = 1.01; // ball-center spacing in the rack as a multiple of BALL_DIAM. 1.0 = touching (Box2D doesn't like this — forces cascade weirdly). Try 1.02–1.08 to taste.
const POCKET_DIAM = 1.9;
const POCKET_INSET = -0.1; // pocket-center distance inward from felt edge (negative pushes pockets outward toward/past the rail)
const MAX_HIT = 12; // max aim length in world units — stretch this far for full power
const HIT_IMPULSE = 5; // impulse per unit of aim (max impulse = MAX_HIT * HIT_IMPULSE = 60)
const MIN_IMPULSE = 2; // floor — clicking at 0% power still gives the cue a soft tap
const AIM_DEADZONE = BALL_DIAM; // mouse within this radius of cue ball = 0 power (free direction aiming)
const WHEEL_STEP = .08; // wheel ticks per unit of added power (≈12 ticks across full range)
const STOP_SPEED = 0.15;
const RAPID_STOP_THRESHOLD = 1.0; // below this world-units/sec we squash velocity each frame to end turns faster
const RAPID_STOP_FACTOR = 0.95; // velocity multiplier applied each frame while in the rapid-stop band
// Visual tuning
const SHADOW_ALPHA = .5; // shared shadow opacity for balls + cue stick (0 = invisible, 1 = solid black)
const OUTLINE_WIDTH = .03; // extra radius past the ball edge for the black outline ring (0 = no outline)
const SINK_END_SCALE = .7; // ball scale at the END of the sink animation (1 = no shrink, 0 = vanish to a point)
// Cue stick dimensions (world units)
const CUE_LENGTH = 14; // length from leather tip to butt
const CUE_TIP_HW = .07; // shaft half-width at the tip (full width = 2x this)
const CUE_BUTT_HW = .14; // shaft half-width at the butt (taper grows from tip to butt)
// Ball physics — adjust these to change the feel
const BALL_RESTITUTION = .92; // bounciness on ball/rail contacts (0 = dead, 1 = perfect bounce)
const BALL_FRICTION = .18; // contact friction; higher = more spin transfer on collisions
const BALL_LIN_DAMP = .5; // linear damping — rolling resistance from the felt
const BALL_ANG_DAMP = .3; // angular damping — how fast spin dies off
// Rail physics — controls how cushions feel separately from the balls
const RAIL_RESTITUTION = .85;
const RAIL_FRICTION = .15;
// pool ball colors (index = ball number)
const ballColors = [
rgb(.95,.95,.95), // 0 - cue (white)
rgb(1, .82, .05), // 1 - yellow
rgb(.10,.25,.78), // 2 - blue
rgb(.85,.10,.10), // 3 - red
rgb(.45,.10,.55), // 4 - purple
rgb(.96,.50,.05), // 5 - orange
rgb(.05,.55,.25), // 6 - green
rgb(.55,.15,.10), // 7 - maroon
rgb(.08,.08,.08), // 8 - black
];
function ballColor(n)
{
if (n <= 8) return ballColors[n];
return ballColors[n - 8]; // 9..15 use same colors but get a stripe
}
function isStriped(n) { return n > 8; }
// game state
let cueBall;
let balls = []; // numbered balls (1..15) still on table
let pockets = [];
let railObj; // static body for rails
let shots = 0;
let isPlaying = false;
let won = false;
let scratchedThisShot = false;
let inHand = false; // true after a scratch — cue ball follows the mouse until placed
let wheelPower = 0; // wheel-driven power offset added on top of mouse drag (cleared on each shot)
let sinking = []; // visual-only sink animations — physics already destroyed; see updateSinking
const SINK_TIME = .5; // seconds to lerp from contact point to pocket center
// Best (lowest) shot count to clear the table, persisted across sessions.
const BEST_KEY = 'pool.bestShots';
let bestShots = +localStorage.getItem(BEST_KEY) || 0;
let newBestThisRound = false;
// strike animation — windup (tip travels toward ball) then follow-through.
// Set when the player clicks; cleared after the swing finishes. While set,
// canShoot() returns false so no second click can interrupt the swing.
let strike = null;
// Strike timing — pullback scales with power so heavy shots have a longer
// windup (more dramatic). Forward + follow-through stay constant.
const STRIKE_PULLBACK_MIN_S = .10; // pullback time at 0% power
const STRIKE_PULLBACK_MAX_S = .28; // pullback time at 100% power
const STRIKE_FORWARD_S = .06; // time to drive the cue forward into the ball
const STRIKE_FOLLOW_S = .08; // follow-through after contact
// sounds
let sfxHit, sfxRail, sfxBreak, sfxPocket, sfxScratch, sfxWin;
// Pre-rendered ball sprites — index = ball number (0 = cue, 1..15 = numbered).
// The flat body (color + stripe + number) is baked into a tile so the number
// always renders inside the ball's own pixel bounds, never on top of other
// balls (the old canvas-2D number drew its disc over whatever was nearby).
// Shadow + specular are still drawn separately at runtime (the shadow because
// it lives on the felt, the specular because it shouldn't rotate with the ball).
let ballTiles = [];
///////////////////////////////////////////////////////////////////////////////
// helpers — drawing
function drawTable()
{
// wood rail border
drawRect(vec2(), TABLE_SIZE.add(vec2(RAIL_THICK*2)), rgb(.30,.16,.06));
// inner wood highlight
drawRect(vec2(), TABLE_SIZE.add(vec2(RAIL_THICK*1.55)), rgb(.42,.24,.10));
// dark inner trim
drawRect(vec2(), TABLE_SIZE.add(vec2(.6)), rgb(.05,.04,.02));
// felt
drawRect(vec2(), TABLE_SIZE, rgb(.10,.42,.20));
// diamond markers along the rails (decorative). Long axis points
// perpendicular to the rail it sits on — tall on horizontal (long) rails,
// wide on vertical (short) rails — for the inlaid-marker look.
const dx = TABLE_SIZE.x / 8;
const dy = TABLE_SIZE.y / 4;
const railY = TABLE_SIZE.y/2 + RAIL_THICK/2.2;
const railX = TABLE_SIZE.x/2 + RAIL_THICK/2.2;
for (let i = -3; i <= 3; i++)
{
if (i === 0) continue;
drawDiamond(vec2(i*dx, railY), true);
drawDiamond(vec2(i*dx, -railY), true);
}
// Short rails get 3 diamonds (no side pocket to dodge in the middle, unlike long rails).
for (let i = -1; i <= 1; i++)
{
drawDiamond(vec2( railX, i*dy), false);
drawDiamond(vec2(-railX, i*dy), false);
}
// head string / foot spot dots
const headX = -TABLE_SIZE.x/4;
drawLine(vec2(headX, -TABLE_SIZE.y/2), vec2(headX, TABLE_SIZE.y/2), .1, rgb(1,1,1,.10));
drawCircle(vec2(TABLE_SIZE.x/4, 0), .2, rgb(1,1,1,.25));
drawCircle(vec2(headX, 0), .2, rgb(1,1,1,.25));
}
// Rhombus marker for rail sights. `tall` = true → long axis vertical (used on
// horizontal/long rails); false → long axis horizontal (used on short rails).
function drawDiamond(pos, tall)
{
const long = .28, short = .14;
const w = tall ? short : long;
const h = tall ? long : short;
drawPoly([
pos.add(vec2(0, h)),
pos.add(vec2( w, 0)),
pos.add(vec2(0, -h)),
pos.add(vec2(-w, 0)),
], rgb(.95,.92,.85));
}
// Split into two passes so sinking balls can be drawn BETWEEN the soft pocket
// shadow and the solid black hole — the hole then covers the shrinking ball
// as it descends, selling the "falling in" illusion.
function drawPocketShadow(pos)
{
drawCircleGradient(pos, POCKET_DIAM*2, rgb(0,0,0,.65), rgb(0,0,0,0));
}
function drawPocketHole(pos)
{
drawCircle(pos, POCKET_DIAM*1.10, rgb(0,0,0,1));
drawCircle(pos, POCKET_DIAM*0.90, rgb(.08,.04,.02,1));
}
// Render a sinking ball at its lerped position, shrinking as it descends.
// `SINK_END_SCALE` controls how small it gets at t=1; `SINK_TIME` controls how long.
function drawSinkingBall(s)
{
const pos = s.fromPos.add(s.toPos.subtract(s.fromPos).scale(s.t));
const scale = lerp(1, SINK_END_SCALE, s.t);
drawTile(pos, vec2(BALL_DIAM * scale), ballTiles[s.number], WHITE, 0);
}
// Cue tip distance from ball center at each animation keyframe.
// While aiming the stick stays at REST (independent of power). The strike
// animates REST → pullback (scaled by power) → CONTACT → FOLLOW.
const CUE_REST_DIST = BALL_DIAM*.5 + .15; // rest position while aiming at zero power
const REST_PULL_SCALE = .6; // subtle pre-shot drift back, scaled by current power
const CUE_PULL_SCALE = 4; // extra pullback during the strike, scaled by power
const CUE_CONTACT_DIST = BALL_DIAM*.5; // tip touches ball surface
const CUE_FOLLOW_DIST = BALL_DIAM*.5 - .25; // tip overshoots into the ball
// Cue stick rendering is split so the shadow can be drawn during the
// pre-objects pass (in gameRender) and the body during the post pass (in
// gameRenderPost). Same pose is used by both — see getCueStickPose.
function drawCueStickShadow(ballPos, dir, tipDist)
{
const back = dir.scale(-1);
const offset = vec2(.12, -.12);
const tip = ballPos.add(back.scale(tipDist)).add(offset);
const butt = ballPos.add(back.scale(tipDist + CUE_LENGTH)).add(offset);
tapPoly(tip, butt, dir, CUE_TIP_HW, CUE_BUTT_HW, rgb(0, 0, 0, SHADOW_ALPHA));
}
function drawCueStickBody(ballPos, dir, tipDist)
{
const back = dir.scale(-1);
const tip = ballPos.add(back.scale(tipDist));
const butt = ballPos.add(back.scale(tipDist + CUE_LENGTH));
// tapered shaft (light wood)
tapPoly(tip, butt, dir, CUE_TIP_HW, CUE_BUTT_HW, rgb(.86,.72,.42));
// wrap section near the butt — same width as the shaft at that position
// (just a darker color band on the same trapezoid)
const wrapT0 = .72, wrapT1 = .93;
const wrapA = ballPos.add(back.scale(tipDist + CUE_LENGTH * wrapT0));
const wrapB = ballPos.add(back.scale(tipDist + CUE_LENGTH * wrapT1));
const wrapHW0 = lerp(CUE_TIP_HW, CUE_BUTT_HW, wrapT0);
const wrapHW1 = lerp(CUE_TIP_HW, CUE_BUTT_HW, wrapT1);
tapPoly(wrapA, wrapB, dir, wrapHW0, wrapHW1, rgb(.18, .08, .04));
// ferrule + leather tip (at the front)
const ferB = ballPos.add(back.scale(tipDist + .25));
drawLine(tip, ferB, CUE_TIP_HW * 2 + .02, rgb(.95,.95,.90));
drawCircle(tip, CUE_TIP_HW * 2, rgb(.30,.18,.55));
}
// Returns the cue stick pose for the current frame, or null if no stick
// should be drawn. Pure function so both shadow- and body- passes agree.
//
// Aiming: stick sits at CUE_REST_DIST + a small power-scaled drift back as a
// pre-shot visual cue (more power → stick already a touch further back).
// Strike: animates REST → pullback (scaled by power) → CONTACT → FOLLOW, with
// the pullback lerp starting from the same drifted rest position so there's
// no snap when the swing begins.
const cueRestDist = (power) => CUE_REST_DIST + power * REST_PULL_SCALE;
function getCueStickPose()
{
if (!isPlaying || !cueBall) return null;
if (strike)
{
let tipDist;
if (strike.pullbackT < 1)
tipDist = lerp(cueRestDist(strike.power), strike.pullbackDist, strike.pullbackT);
else if (!strike.hit)
tipDist = lerp(strike.pullbackDist, CUE_CONTACT_DIST, strike.strikeT);
else
tipDist = lerp(CUE_CONTACT_DIST, CUE_FOLLOW_DIST, strike.followT);
return { dir: strike.dir, tipDist };
}
const showAim = !isTouchDevice || mouseIsDown(0);
if (canShoot() && showAim)
{
const { dir, power } = getAim();
return { dir, tipDist: cueRestDist(power) };
}
return null;
}
function drawBallShadow(pos)
{
drawCircle(pos.add(vec2(.10,-.12)), BALL_DIAM*1.02, rgb(0,0,0, SHADOW_ALPHA));
}
// Black backplate drawn just behind the ball tile so a thin dark ring shows
// past the ball's pixel edge — reads as an inked outline and separates balls
// from the felt + each other. Drawn in the pre-pass with the shadows.
function drawBallOutline(pos)
{
// Render via drawTile (WebGL) tinted black instead of canvas-2D drawCircle.
// Any ball tile works since they're all circular — using tile 0 (cue ball)
// because its interior is uniformly opaque so the BLACK tint covers it fully.
drawTile(pos, vec2(BALL_DIAM + OUTLINE_WIDTH*2), ballTiles[0], BLACK);
}
// Tapered-trapezoid quad. Used for the cue shaft + its shadow so the stick
// reads as wider at the butt than at the tip.
function tapPoly(start, end, dir, startHW, endHW, color)
{
const perp = vec2(-dir.y, dir.x);
drawPoly([
start.add(perp.scale(startHW)),
start.subtract(perp.scale(startHW)),
end.subtract(perp.scale(endHW)),
end.add(perp.scale(endHW)),
], color);
}
// Paint one ball's flat sprite into a 500×500 tile. Includes everything that
// should rotate with the ball: color, stripe, number circle, number digit.
// Excludes shadow + specular (rendered separately at runtime).
function paintBallTile(ctx, number)
{
const cx = 250, cy = 250, r = 240;
const color = ballColor(number).toString();
// body
ctx.fillStyle = number === 0 ? '#ffffff' : color;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fill();
if (number === 0) return; // cue is plain white, no number
if (isStriped(number))
{
// repaint as white base, then a colored band clipped to the circle
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.clip();
ctx.fillStyle = color;
const bandH = r * .48; // band half-height, matches the world-space value
ctx.fillRect(cx - r, cy - bandH, r*2, bandH*2);
ctx.restore();
}
// white number disc
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(cx, cy, r * .34, 0, Math.PI*2);
ctx.fill();
// number digit
ctx.fillStyle = '#000000';
ctx.font = `bold ${Math.round(r * .44)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('' + number, cx, cy);
}
///////////////////////////////////////////////////////////////////////////////
// physics objects
class Ball extends Box2dObject
{
constructor(pos, number=0)
{
super(pos, vec2(BALL_DIAM), 0, 0, ballColor(number));
this.number = number;
// pool ball physics — see the BALL_* constants at the top of the file.
// friction transfers angular momentum on contact (spin on number/stripe);
// restitution controls how bouncy ball-vs-ball / ball-vs-rail is;
// damping controls how quickly motion and spin decay over time.
this.addCircle(BALL_DIAM, vec2(), 1, BALL_FRICTION, BALL_RESTITUTION);
this.setLinearDamping(BALL_LIN_DAMP);
this.setAngularDamping(BALL_ANG_DAMP);
this.setBullet(true); // continuous collision so balls don't tunnel through walls at speed
}
beginContact(other)
{
// Pocket sensors are handled by update()/pocketed — no contact sound here.
if (other instanceof Pocket) return;
const s = this.getSpeed();
if (s <= 1) return;
const vol = clamp(s/16, .15, 1);
if (other instanceof Ball)
// sharp click for ball-vs-ball
sfxHit.play(this.pos, vol, 1 + (this.number ? .02 : 0));
else
// softer thud for ball-vs-rail (or any other static body)
sfxRail.play(this.pos, vol * .7);
}
update()
{
super.update();
if (this.pocketed)
{
// Start the sink animation. Physics body is destroyed immediately
// so the ball stops interacting with anything; updateSinking()
// lerps it visually into the pocket center and fires the actual
// "sunk" effects (scratch/win/best score) once the lerp completes.
const isCue = (this === cueBall);
sinking.push({
number: this.number,
fromPos: this.pos.copy(),
toPos: this.pocketPos || this.pos.copy(),
t: 0,
isCue,
});
if (isCue)
{
sfxScratch.play();
this.destroy();
cueBall = null;
}
else
{
sfxPocket.play(undefined, .9);
this.destroy();
const i = balls.indexOf(this);
if (i >= 0) balls.splice(i, 1);
}
}
}
render()
{
// Ball body — pre-rendered tile rotates with the ball, so the number
// and stripe are always painted within the ball's own pixel bounds
// (no more number circles sitting on top of neighbor balls).
drawTile(this.pos, vec2(BALL_DIAM), ballTiles[this.number], WHITE, this.angle);
// Specular highlights — drawn AFTER the tile, NOT rotated, so they
// read as a fixed light source rather than a painted-on detail.
if (this.number === 0)
{
drawCircleGradient(this.pos.add(vec2(-.2, .2)), BALL_DIAM*.5, rgb(1,1,1,.7), rgb(1,1,1,0));
drawCircleGradient(this.pos.add(vec2( .2,-.2)), BALL_DIAM*.5, rgb(0,0,0,.3), rgb(0,0,0,0));
}
else
{
drawCircleGradient(this.pos.add(vec2(-.2, .2)), BALL_DIAM*.5, rgb(1,1,1,.6), rgb(1,1,1,0));
}
}
}
class Pocket extends Box2dStaticObject
{
constructor(pos)
{
super(pos);
this.pos = pos.copy();
this.addCircle(POCKET_DIAM, vec2(), 0, 0, 0, true); // sensor
}
beginContact(other)
{
// Record which pocket caught the ball so Ball.update can animate the
// sink toward this pocket's center.
if (other.pocketed) return; // already on its way
other.pocketed = 1;
other.pocketPos = this.pos.copy();
}
render() {} // drawn in gameRender so it sits under the balls
}
///////////////////////////////////////////////////////////////////////////////
// table / rack setup
function buildTable()
{
// boundary walls (rectangular for now — pocket openings are handled by
// pockets sensoring the ball before it reaches the corner)
railObj = new Box2dStaticObject;
railObj.color = rgb(0,0,0,0); // invisible — drawTable() paints the rails
railObj.render = () => {};
const halfX = TABLE_SIZE.x/2 + WALL_THICK/2;
const halfY = TABLE_SIZE.y/2 + WALL_THICK/2;
railObj.addBox(vec2(TABLE_SIZE.x + WALL_THICK*2, WALL_THICK), vec2(0, halfY), 0, 0, RAIL_FRICTION, RAIL_RESTITUTION);
railObj.addBox(vec2(TABLE_SIZE.x + WALL_THICK*2, WALL_THICK), vec2(0, -halfY), 0, 0, RAIL_FRICTION, RAIL_RESTITUTION);
railObj.addBox(vec2(WALL_THICK, TABLE_SIZE.y), vec2( halfX, 0), 0, 0, RAIL_FRICTION, RAIL_RESTITUTION);
railObj.addBox(vec2(WALL_THICK, TABLE_SIZE.y), vec2(-halfX, 0), 0, 0, RAIL_FRICTION, RAIL_RESTITUTION);
// 6 pockets — 4 corners + 2 side. POCKET_INSET (top of file) tunes how
// far in/out from the felt edge they sit; negative pushes them outward.
const px = TABLE_SIZE.x/2 - POCKET_INSET;
const py = TABLE_SIZE.y/2 - POCKET_INSET;
pockets.push(new Pocket(vec2(-px, -py)));
pockets.push(new Pocket(vec2( px, -py)));
pockets.push(new Pocket(vec2(-px, py)));
pockets.push(new Pocket(vec2( px, py)));
pockets.push(new Pocket(vec2( 0, -py)));
pockets.push(new Pocket(vec2( 0, py)));
}
function rackBalls()
{
// racked at the "foot spot" — quarter of the table from the right rail
const apex = vec2(TABLE_SIZE.x/4, 0);
// standard 5-row triangle, ball numbers placed in a fixed order
const rackNumbers = [
[1],
[9, 2],
[10, 8, 3],
[11, 4, 12, 5],
[6, 13, 7, 14, 15],
];
const dy = BALL_DIAM * RACK_SPACING;
const dx = dy * Math.cos(Math.PI/6);
for (let row = 0; row < rackNumbers.length; row++)
{
for (let col = 0; col <= row; col++)
{
const n = rackNumbers[row][col];
const x = apex.x + row * dx;
const y = apex.y + (col - row/2) * dy;
balls.push(new Ball(vec2(x, y), n));
}
}
}
function spawnCueBall()
{
cueBall = new Ball(vec2(-TABLE_SIZE.x/4, 0), 0);
}
function clearTable()
{
if (cueBall) { cueBall.destroy(); cueBall = null; }
for (const b of balls) b.destroy();
balls.length = 0;
for (const p of pockets) p.destroy();
pockets.length = 0;
if (railObj) { railObj.destroy(); railObj = null; }
}
function newGame()
{
clearTable();
buildTable();
rackBalls();
spawnCueBall();
shots = 0;
won = false;
scratchedThisShot = false;
inHand = false;
newBestThisRound = false;
strike = null;
wheelPower = 0;
sinking.length = 0;
}
// Clamp a world-space position so the cue ball stays inside the felt.
function clampToFelt(pos)
{
const m = BALL_DIAM*.5;
return vec2(
clamp(pos.x, -TABLE_SIZE.x/2 + m, TABLE_SIZE.x/2 - m),
clamp(pos.y, -TABLE_SIZE.y/2 + m, TABLE_SIZE.y/2 - m),
);
}
// Is `pos` a safe spot to drop the cue ball? Buffers a small extra distance
// off the rails, away from every other ball, and far enough from every pocket
// that the sensor won't immediately fire.
function isValidPlacement(pos)
{
// Felt boundary with a small extra gap past the ball radius
const railM = BALL_DIAM*.5 + .05;
if (Math.abs(pos.x) > TABLE_SIZE.x/2 - railM) return false;
if (Math.abs(pos.y) > TABLE_SIZE.y/2 - railM) return false;
// Pockets — keep enough distance that the sensor radius doesn't overlap
const minPocketDist = BALL_DIAM*.5 + POCKET_DIAM*.5 + .1;
for (const p of pockets)
if (pos.distance(p.pos) < minPocketDist) return false;
// Other balls — small extra gap past tangent
const minBallDist = BALL_DIAM * 1.05;
for (const b of balls)
if (pos.distance(b.pos) < minBallDist) return false;
return true;
}
// Draws the cue-ball ghost while in-hand placement is active. Tinted red
// when the target spot is invalid so the player can see WHY they can't drop.
function drawCueBallPreview(pos, valid)
{
drawBallShadow(pos);
drawBallOutline(pos);
const tint = valid ? WHITE : rgb(1, .35, .35);
drawTile(pos, vec2(BALL_DIAM), ballTiles[0], tint);
drawCircleGradient(pos.add(vec2(-.2, .2)), BALL_DIAM*.5, rgb(1,1,1,.7), rgb(1,1,1,0));
drawCircleGradient(pos.add(vec2( .2,-.2)), BALL_DIAM*.5, rgb(0,0,0,.3), rgb(0,0,0,0));
}
///////////////////////////////////////////////////////////////////////////////
// aim / shoot
function allStopped()
{
if (sinking.length) return false; // wait for sink animations before declaring the turn over
if (cueBall && cueBall.getSpeed() > STOP_SPEED) return false;
for (const b of balls)
if (b.getSpeed() > STOP_SPEED) return false;
return true;
}
function canShoot()
{
return isPlaying && !won && cueBall && !strike && !inHand && allStopped();
}
function getAim()
{
// Direction comes from the mouse position relative to the cue ball.
// Power has two contributions:
// 1) Drag — distance past an AIM_DEADZONE around the ball, scaled into 0..1.
// This lets the player keep the cursor at a comfortable distance for
// aiming weak shots without pinning it on the ball.
// 2) Wheel — accumulated wheel ticks (up = more, down = less) added on top.
// Lets the player fine-tune without having to drag.
// The two are summed and clamped, then `len` is back-derived so the cue
// stick / arrow / power readout all stay in sync with whatever the player
// is actually committing to.
const d = mousePos.subtract(cueBall.pos);
const rawLen = d.length();
const dir = rawLen > 0 ? d.normalize() : vec2(1, 0);
const dragLen = max(0, rawLen - AIM_DEADZONE);
const basePower = min(dragLen / MAX_HIT, 1);
const power = clamp(basePower + wheelPower, 0, 1);
const len = power * MAX_HIT;
return { dir, power, len };
}
///////////////////////////////////////////////////////////////////////////////
// menus + input
function setPlaying(p)
{
isPlaying = p;
const hud = getToolbar('hud');
if (hud) p ? hud.show() : hud.hide();
}
function startPlay()
{
// createTitleMenu auto-hides 'title' after onPlay returns.
newGame();
setPlaying(true);
}
async function gameInit()
{
await box2dInit();
// Pre-render all 16 ball sprites into the texture atlas. Tile index = ball number.
initDrawToTexture();
for (let n = 0; n <= 15; n++)
{
const desc = n === 0 ? 'cue ball (white)'
: isStriped(n) ? `striped pool ball ${n}` : `solid pool ball ${n}`;
ballTiles[n] = drawToTexture(n, ctx => paintBallTile(ctx, n), desc);
}
// canvas fit
canvasClearColor = rgb(.05,.07,.05);
setCanvasFixedSize(vec2(1280, 720));
cameraPos = vec2();
cameraScale = 33;
// sounds — short ZzFX clicks for cushion / ball contacts
sfxHit = new Sound([,.1, 2e3, , , .01, , , , , , , , 1]);
// softer, lower thud for ball-vs-rail contact — placeholder, swap when you have a real sample
sfxRail = new SoundGenerator({volume:.45, frequency:95, attack:0, release:.14, shapeCurve:2.2, slide:-.6, noise:.04});
sfxPocket = new SoundGenerator({volume:.8, frequency:180, attack:0, release:.18, shapeCurve:1.2, slide:-2.2, noise:.04});
sfxScratch = new SoundGenerator({volume:.7, frequency:120, attack:0, release:.30, shapeCurve:1.6, slide:-1.4, noise:.10});
sfxBreak = new SoundGenerator({volume:.9, frequency:240, attack:0, release:.20, shapeCurve:1.3, slide:-1.8, noise:.06});
sfxWin = new SoundGenerator({volume:.9, frequency:520, attack:0, release:.45, shapeCurve:1.1, slide:1.5, pitchJump:200, pitchJumpTime:.12});
// pause whenever any menu is visible
setMenuVisibilityCallback(v => paused = v);
// global UI sounds
const sound_select = new Sound([.4,,910,,,.02,2,.07,-5,-33,,,,,,,,.25]);
const sound_activate = new Sound([.6,,30,.01,,.02,1,3.4,94,,,,,,,,,.67]);
setMenuSounds({
select: () => sound_select.play(),
activate: () => sound_activate.play(),
});
// ----- title menu (no click-to-reveal — surfaced immediately at end of init) -----
createTitleMenu({
title: 'POOL',
subtitle: 'Sink them all',
onPlay: startPlay,
revealOnClick: false,
// Best score sits above PLAY. onShow re-syncs the label every time
// the title is surfaced so a fresh win updates it before the next round.
itemsBefore: [
{type:'label', id:'bestLabel', text: bestShots ? `BEST ${bestShots}` : ''},
],
onShow: () =>
{
const item = getMenu('title')?.getItem('bestLabel');
item?.setLabel(bestShots ? `BEST ${bestShots}` : '');
},
items: [
{type:'button', label:'ABOUT', onClick: () => pushMenu('about')},
],
});
// ----- about menu -----
createMenu({
id: 'about',
title: 'HOW TO PLAY',
onHide: popMenu,
items: [
{type:'text', text:'Move the mouse to aim from the cue ball. ' +
'Distance from the cue ball sets your shot power (longer = harder). ' +
'Click to break.\n\n' +
'Sink all 15 balls in as few shots as possible. ' +
'Scratching (sinking the cue ball) costs you a stroke and respawns the cue.'},
{type:'separator'},
{type:'button', label:'BACK', onClick: () => hideMenu('about')},
],
});
// ----- pause menu -----
createMenu({
id: 'pause',
title: 'PAUSED',
initialItemId: 'resume',
items: [
{type:'button', id:'resume', label:'RESUME', onClick: () => hideMenu('pause')},
{type:'button', id:'rack', label:'NEW RACK', onClick: () =>
{
showConfirmDialog({
message: 'Start a fresh rack?',
onYes: () => { newGame(); hideMenu('pause'); },
});
}},
{type:'button', id:'quit', label:'QUIT TO TITLE', onClick: () =>
{
showConfirmDialog({
message: 'Quit to title?',
onYes: () =>
{
clearSubmenuStack();
hideAllMenus();
newGame(); // reset so the title backdrop shows a fresh rack
setPlaying(false);
showMenu('title');
},
});
}},
],
});
// ----- HUD toolbar -----
createToolbar({
id: 'hud',
anchor: 'top-right',
direction: 'horizontal',
items: [
{type:'button', id:'fs', label:'⛶', title:'Fullscreen', onClick: toggleFullscreen, hideOnTouch:true},
{type:'button', id:'menu', label:'☰', title:'Menu', onClick: () =>
{
if (isMenuVisible()) { clearSubmenuStack(); hideAllMenus(); }
else showMenu('pause');
}},
],
});
getToolbar('hud')?.hide();
// Rack the table once so the title menu has gameplay behind it instead
// of a black screen, then surface the title (menus.js pauses physics
// automatically, so the rack just sits there until PLAY).
newGame();
showMenu('title');
}
///////////////////////////////////////////////////////////////////////////////
function gameUpdate()
{
if (!isPlaying) return;
// Esc / Start opens pause
if (bindPauseKey({when: () => isPlaying})) return;
// Auto-respawn cue ball at the head spot — skipped during ball-in-hand
// (placement is committed manually in the inHand block below).
if (!cueBall && allStopped() && !won && !inHand)
spawnCueBall();
// Ball-in-hand placement — no physics object exists yet, we just preview
// a ghost ball at the cursor. Commit (click on mouse / release on touch)
// creates the real cue ball, but ONLY if the spot is valid (clear of
// rails, other balls, and pockets) and everything else has stopped.
if (inHand)
{
const targetPos = clampToFelt(mousePos);
const valid = isValidPlacement(targetPos);
const commitInput = isTouchDevice ? mouseWasReleased(0) : mouseWasPressed(0);
if (commitInput && valid && allStopped())
{
cueBall = new Ball(targetPos, 0);
inHand = false;
}
return;
}
// R restarts the rack
if (keyWasPressed('KeyR'))
{
newGame();
return;
}
// Right mouse held = 5x simulation speed. box2d.step(4) advances physics
// 4 extra ticks per frame on top of the engine's own step. Rapid-stop
// still runs once per frame which is fine — fast-forward is mainly for
// skipping the long coast at the end of a turn.
if (mouseIsDown(2) && !paused)
box2d.step(4);
// Debug: hold D to drag the cue ball to the mouse position. Lets you line
// it up against pocket mouths to verify the sensor catches it at the right
// spot. Aborts any in-flight strike so an impulse doesn't fire from the
// new location, and zeros the velocity so the ball stays put on release.
if (cueBall && keyIsDown('KeyD'))
{
cueBall.setTransform(mousePos, 0);
cueBall.setLinearVelocity(vec2());
cueBall.setAngularVelocity(0);
strike = null;
}
// Shooting — clicking commits to a swing animation; the impulse fires when
// the tip reaches the ball, not on the click frame. Impulse magnitude is
// raw aim length * HIT_IMPULSE so the demo's "feel" is preserved: max
// length 6 × HIT_IMPULSE 8 = max impulse 48.
// Wheel adjusts power: up = more, down = less. LittleJS sets mouseWheel
// to sign(deltaY) per frame, so wheel-down is +1, wheel-up is -1 — we
// subtract so wheel-up increases power.
//
// Re-clamp wheelPower against the current basePower on every wheel event
// so the offset can only sit inside the range that actually contributes
// to the visible 0..1 power. Without this, scrolling at the limit
// accumulates hidden overflow that you have to undo before the visible
// power moves the other way.
if (canShoot() && mouseWheel)
{
const rawLen = mousePos.subtract(cueBall.pos).length();
const basePower = min(max(0, rawLen - AIM_DEADZONE) / MAX_HIT, 1);
wheelPower = clamp(wheelPower - mouseWheel * WHEEL_STEP, -basePower, 1 - basePower);
}
// Touch devices fire on release so the player can press, drag to aim,
// then lift to commit. Mouse keeps the click-to-fire feel.
const shotInput = isTouchDevice ? mouseWasReleased(0) : mouseWasPressed(0);
if (canShoot() && shotInput)
{
const {dir, power, len} = getAim();
// Every click fires — even at 0% power. MIN_IMPULSE keeps it from
// being a no-op so the swing animation always produces motion.
strike = {
dir, power, len,
pullbackDist: CUE_REST_DIST + power * CUE_PULL_SCALE,
pullbackT: 0, // 0..1 progress pulling back from REST
strikeT: 0, // 0..1 progress driving forward from pullback to contact
hit: false, // has the impulse been applied?
followT: 0, // 0..1 progress through follow-through after contact
};
wheelPower = 0; // each shot starts from the mouse-drag baseline
}
// Advance the strike animation each frame: pullback → forward → follow-through
if (strike)
{
if (!cueBall)
{
// cue ball gone (e.g. quit mid-swing) — bail out
strike = null;
}
else if (strike.pullbackT < 1)
{
const pullbackDur = lerp(STRIKE_PULLBACK_MIN_S, STRIKE_PULLBACK_MAX_S, strike.power);
strike.pullbackT = min(1, strike.pullbackT + timeDelta / pullbackDur);
}
else if (!strike.hit)
{
strike.strikeT = min(1, strike.strikeT + timeDelta / STRIKE_FORWARD_S);
if (strike.strikeT >= 1)
{
// tip just reached the ball — apply impulse now. Lerps from
// MIN_IMPULSE (0% power tap) to the full max impulse.
strike.hit = true;
const impulseMax = MAX_HIT * HIT_IMPULSE;
const impulse = lerp(MIN_IMPULSE, impulseMax, strike.power);
cueBall.applyImpulse(strike.dir.scale(impulse));
shots++;
scratchedThisShot = false;
(shots === 1 ? sfxBreak : sfxHit).play(undefined, .8);
}
}
else
{
strike.followT = min(1, strike.followT + timeDelta / STRIKE_FOLLOW_S);
if (strike.followT >= 1) strike = null;
}
}
}
function gameUpdatePost()
{
setCameraPos(vec2());
// Rapid stop — once a ball is slow (below RAPID_STOP_THRESHOLD) but not yet
// under STOP_SPEED, hammer its velocity each frame so turns don't drag on
// while everything coasts. Above the threshold, normal damping handles it.
rapidStop(cueBall);
for (const b of balls) rapidStop(b);
updateSinking();
}
// Advance sink animations. When an entry reaches t = 1 the ball is "really"
// sunk — set scratch / win flags here, not at the moment of pocket contact,
// so the player sees the suck-in finish before any state change kicks in.
function updateSinking()
{
let numberedSunk = false;
for (let i = sinking.length - 1; i >= 0; i--)
{
const s = sinking[i];
s.t = min(1, s.t + timeDelta / SINK_TIME);
if (s.t >= 1)
{
if (s.isCue)
{
scratchedThisShot = true;
inHand = true;
}
else
{
numberedSunk = true;
}
sinking.splice(i, 1);
}
}
// Win fires only after the last numbered ball finishes sinking. Gate on
// sinking.length too in case the cue scratched at the same time — wait
// until everything has finished animating.
if (numberedSunk && !won && !balls.length && !sinking.length)
{
won = true;
if (!bestShots || shots < bestShots)
{
bestShots = shots;
localStorage.setItem(BEST_KEY, bestShots);
newBestThisRound = true;
}
sfxWin.play();
}
}
function rapidStop(b)
{
if (!b) return;
const s = b.getSpeed();
if (s > STOP_SPEED && s < RAPID_STOP_THRESHOLD)
b.setLinearVelocity(b.getLinearVelocity().scale(RAPID_STOP_FACTOR));
}
///////////////////////////////////////////////////////////////////////////////
function gameRender()
{
// Drawn every frame — table + pockets sit behind the title menu
// as a backdrop. Numbered balls / cue ball render themselves as
// engine objects (also always-on), so the rack is visible too.
drawTable();
// Pocket render — sinking balls draw ON TOP of the pocket hole so they
// stay visible all the way through the animation (instead of being
// covered by the black hole as they approach the center).
for (const p of pockets) drawPocketShadow(p.pos);
for (const p of pockets) drawPocketHole(p.pos);
for (const s of sinking) drawSinkingBall(s);
// Shadow pre-pass — ball + cue-stick shadows are drawn here so the
// engine's object render pass paints balls and the gameRenderPost stick
// body land on top of clean shadows. Without this, late-rendered balls
// would stamp their shadows over earlier balls.
for (const b of balls) drawBallShadow(b.pos);
if (cueBall) drawBallShadow(cueBall.pos);
// Black outline backplate — sits just behind each ball tile so a thin