8-ball-pool wierd physics behavior

pool_balls.glb (957.2 KB)
pool_table.glb (1.1 MB)
cueStick_AlexPereira_cue

Above uploaded are my two file which am using for the table and the balls of the pool.
Below is my code -

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>8 Ball Pool 3D</title>
    <link rel="stylesheet" href="src/style.css" />
  </head>
  <body>
    <form style="--min: 0; --max: 100; --val: 0">
      <input type="range" min="0" max="100" value="0" list="l" />
      <datalist id="l">
        <option label="min" value="0"></option>
        <option label="max" value="100"></option>
      </datalist>
    </form>
    <div id="app"></div>
    <script type="module" src="src/script.js"></script>
  </body>
</html>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  color: #fff;
  font-family: Arial, sans-serif;
  overflow: hidden;
}

#app {
  width: 100vw;
  height: 100vh;
  position: relative;
  overflow: hidden;
}

form {
  --k: calc((var(--val) - var(--min)) / (var(--max) - var(--min)));
  --pos: calc(1.125em + var(--k) * (100% - 2.25em));
  grid-gap: 0.25em;
  place-self: center;
  position: absolute;
  left: 70%;
  top: 50%;
  transform: translate(-50%, 0%) rotate(-90deg);
  z-index: 2;
  min-width: 8em;
  width: calc(100% - 1.5em);
  max-width: 19.5em;
  filter: Saturate(var(--hl, 0));
  transition: filter 0.3s ease-out;
}
form:focus-within,
form:hover {
  --hl: 1;
}

input[type="range"] {
  height: 30px;
  width: 300px;
  border-radius: 2.25em;
  box-shadow: 0 -1px #eaeaea, 0 1px #fff;
  background: linear-gradient(#c3c3c3, #f1f1f1);
  cursor: pointer;
}
input[type="range"],
input[type="range"]::-webkit-slider-runnable-track,
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
}
input[type="range"][list]::-webkit-slider-container {
  min-height: 20px;
}
input[type="range"]::-webkit-slider-container {
  -webkit-user-modify: read-write !important;
  margin: 0.375em;
  height: 1.5em;
  border-radius: 0.75em;
  box-shadow: inset 0 1px 4px #8c8c8c;
  background: linear-gradient(#f8dd36, #d68706) 0 / var(--pos) no-repeat,
    linear-gradient(#efefef, #c9c9c9);
}
input[type="range"]::-webkit-slider-runnable-track {
  margin: -0.375em;
}
input[type="range"]::-moz-range-track {
  margin: 0.375em;
  height: 1.5em;
  border-radius: 0.75em;
  box-shadow: inset 0 1px 4px #8c8c8c;
  background: linear-gradient(#f8dd36, #d68706) 0 / var(--pos) no-repeat,
    linear-gradient(#efefef, #c9c9c9);
}
input[type="range"]::-webkit-slider-thumb {
  box-sizing: border-box;
  border: solid 0.375em transparent;
  width: 2.25em;
  height: 2.25em;
  border-radius: 50%;
  box-shadow: 0 2px 5px #7d7d7d;
  background: linear-gradient(#c5c5c5, whitesmoke) padding-box,
    linear-gradient(#fbfbfb, #c2c2c2) border-box;
  cursor: ew-resize;
}
input[type="range"]::-moz-range-thumb {
  box-sizing: border-box;
  border: solid 0.375em transparent;
  width: 2.25em;
  height: 2.25em;
  border-radius: 50%;
  box-shadow: 0 2px 5px #7d7d7d;
  background: linear-gradient(#c5c5c5, whitesmoke) padding-box,
    linear-gradient(#fbfbfb, #c2c2c2) border-box;
  cursor: ew-resize;
}
input[type="range"]:focus {
  outline: none;
}

datalist {
  grid-row: 1;
  grid-template-columns: 3em 1fr 3em;
  place-content: end center;
  margin: 0 -0.375em;
  color: #bababa;
  text-align: center;
  text-transform: uppercase;
}
datalist::after {
  place-self: end center;
  margin-bottom: 3px;
  width: min(12em, 100%);
  min-height: 0.5em;
  grid-area: 1/2;
  background: linear-gradient(90deg, transparent 2px, #f0ba22 0) -1px/1em round;
  clip-path: polygon(0 calc(100% - 1px), 0 100%, 100% 100%, 100% 0);
  content: "";
}
import * as THREE from "three";
import * as CANNON from "cannon-es";
import { sources } from "./config";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import CannonDebugger from "cannon-es-debugger";
import gsap from "gsap";
class Game {
  constructor(sources) {
    this.gameApp = document.getElementById("app");
    this.scene = new THREE.Scene();
    this.sizes = {
      width: window.innerWidth,
      height: window.innerHeight,
      pixelRatio: Math.min(window.devicePixelRatio, 2),
    };
    this.viewSize = 4.4;
    const viewSize = this.viewSize;
    const aspect = this.sizes.width / this.sizes.height;
    this.camera = new THREE.OrthographicCamera(
      -viewSize * aspect,
      viewSize * aspect,
      viewSize,
      -viewSize,
      0.1,
      1000
    );
    this.camera.position.set(0, 20, 0);
    this.camera.rotation.set(0, Math.PI / 2, 0);
    this.camera.lookAt(0, 0, 0);
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
    });
    this.renderer.setClearColor(0x000000);
    this.renderer.setSize(this.sizes.width, this.sizes.height);
    this.renderer.setPixelRatio(this.sizes.pixelRatio);
    this.gameApp.appendChild(this.renderer.domElement);
    this.time = {
      current: Date.now(),
      delta: 16,
    };
    // this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.sources = sources;
    this.loadedAssets = {};
    this.balls = {};
    this.physicsBodies = new Map();
    this.channels = new Map();
    this.ballsStoppedCheckEnabled = false;
    this.initPhysics();
    this.loadAssets().then(() => {
      const stickPos = new THREE.Vector3(0, 2.6, -2);
      stickPos.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
      this.stick = new Stick(stickPos, this.scene, this.loadedAssets, this);
      this.initLighting();
      this.setupTable();
      this.setupBalls();
      this.initPhysicsDebugger();
    });
    this.resize();
    this.update();
    window.onresize = () => this.resize();
    console.log("%cThree.js Scene is initialized", "color: green");
  }

  loadAssets() {
    const textureLoader = new THREE.TextureLoader();
    const glbLoader = new GLTFLoader();
    const promises = [];
    for (const source of this.sources) {
      if (source.type === "glb") {
        promises.push(
          new Promise((resolve, reject) => {
            glbLoader.load(source.path, (gltf) => {
              this.loadedAssets[source.name] = gltf;
              resolve(gltf);
            });
          })
        );
      } else if (source.type === "texture") {
        promises.push(
          new Promise((resolve, reject) => {
            textureLoader.load(source.path, (texture) => {
              this.loadedAssets[source.name] = texture;
              resolve(texture);
            });
          })
        );
      }
    }
    return Promise.all(promises);
  }

  setupTable() {
    const table = this.loadedAssets.table;
    table.scene.traverse((child) => {
      if (child.isMesh) {
        child.castShadow = true;
        child.receiveShadow = true;
      }
    });
    const tableScene = table.scene.clone();
    tableScene.rotation.y += Math.PI;
    this.scene.add(tableScene);
    this.createTablePhysicsBody(tableScene);
    console.log("%cTable loaded successfully", "color: red");
  }

  createTablePhysicsBody(tableScene) {
    tableScene.updateMatrixWorld(true);
    const vertices = [];
    const indices = [];
    let vertexOffset = 0;
    tableScene.traverse((child) => {
      if (!child.isMesh || !child.geometry?.attributes.position) return;
      const positions = child.geometry.attributes.position.array;
      const index = child.geometry.index?.array;
      const startVertexIndex = vertexOffset;
      const vertex = new THREE.Vector3();
      for (let i = 0; i < positions.length; i += 3) {
        vertex.set(positions[i], positions[i + 1], positions[i + 2]);
        vertex.applyMatrix4(child.matrixWorld);
        vertices.push(vertex.x, vertex.y, vertex.z);
      }
      if (index) {
        for (let i = 0; i < index.length; i++) {
          indices.push(index[i] + startVertexIndex);
        }
      } else {
        const vertexCount = positions.length / 3;
        for (let i = 0; i < vertexCount; i += 3) {
          if (i + 2 < vertexCount) {
            indices.push(
              startVertexIndex + i,
              startVertexIndex + i + 1,
              startVertexIndex + i + 2
            );
          }
        }
      }
      vertexOffset += positions.length / 3;
    });
    if (vertices.length > 0 && indices.length > 0) {
      const tableBody = new CANNON.Body({ mass: 0 });
      tableBody.addShape(new CANNON.Trimesh(vertices, indices));
      tableBody.material = this.tableMaterial;
      this.world.addBody(tableBody);
      this.physicsBodies.set("table", tableBody);
    }
  }

  initPhysics() {
    this.world = new CANNON.World();
    this.world.gravity.set(0, -9.82, 0);
    this.world.broadphase = new CANNON.NaiveBroadphase();
    this.world.solver.iterations = 10;
    this.fixedTimeStep = 1 / 60;
    this.maxSubSteps = 3;
    this.tableMaterial = new CANNON.Material("table");
    this.tableMaterial.friction = 0.14;
    this.tableMaterial.restitution = 0.03;
    this.holeBodies = [];
  }

  initLighting() {
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.1);
    this.scene.add(ambientLight);
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(10, 10, 0);
    directionalLight.castShadow = true;
    this.scene.add(directionalLight);
  }

  initPhysicsDebugger() {
    // this.cannonDebugger = new CannonDebugger(this.scene, this.world);
  }

  arrangeBallsInTriangle(balls) {
    const eightBall = balls.find((ball) => ball.name.includes("Ball8"));
    const otherBalls = balls.filter((ball) => !ball.name.includes("Ball8"));

    balls[0].updateMatrixWorld(true);

    const ballRadius =
      Math.max(
        ...new THREE.Box3()
          .setFromObject(balls[0])
          .getSize(new THREE.Vector3())
          .toArray()
      ) / 2;

    const ballDiameter = ballRadius * 2;
    const rows = [1, 2, 3, 4, 5];

    const rackCenterX = -2;
    const rackCenterZ = 0;
    const rackY = 2.57;
    const centerRowIndex = 2;

    const centerRowX = rackCenterX - centerRowIndex * ballDiameter * 0.9;
    eightBall.position.set(rackCenterZ, rackY, centerRowX);

    let ballIndex = 0;

    rows.forEach((count, rowIndex) => {
      const rowX = rackCenterX - rowIndex * ballDiameter * 0.9;
      const startZ = rackCenterZ - ((count - 1) * ballDiameter) / 2;

      for (let i = 0; i < count; i++) {
        if (rowIndex === centerRowIndex && i === Math.floor(count / 2))
          continue;

        if (ballIndex < otherBalls.length) {
          otherBalls[ballIndex].position.set(
            startZ + i * ballDiameter,
            rackY,
            rowX
          );
          ballIndex++;
        }
      }
    });
  }

  setupBalls() {
    const balls = this.loadedAssets.balls;

    balls.scene.scale.set(0.05, 0.05, 0.05);

    this.balls.striped = {};
    this.balls.solid = {};
    this.balls.allBalls = [];

    const ballMap = [
      ["Ball10", (ball) => (this.balls.striped.number_10 = ball)],
      ["Ball11", (ball) => (this.balls.striped.number_11 = ball)],
      ["Ball12", (ball) => (this.balls.striped.number_12 = ball)],
      ["Ball13", (ball) => (this.balls.striped.number_13 = ball)],
      ["Ball14", (ball) => (this.balls.striped.number_14 = ball)],
      ["Ball15", (ball) => (this.balls.striped.number_15 = ball)],
      ["Ball1", (ball) => (this.balls.solid.number_1 = ball)],
      ["Ball2", (ball) => (this.balls.solid.number_2 = ball)],
      ["Ball3", (ball) => (this.balls.solid.number_3 = ball)],
      ["Ball4", (ball) => (this.balls.solid.number_4 = ball)],
      ["Ball5", (ball) => (this.balls.solid.number_5 = ball)],
      ["Ball6", (ball) => (this.balls.solid.number_6 = ball)],
      ["Ball7", (ball) => (this.balls.solid.number_7 = ball)],
      ["Ball8", (ball) => (this.balls.black = ball)],
      ["Ball9", (ball) => (this.balls.striped.number_9 = ball)],
      ["whiteBall", (ball) => (this.balls.white = ball)],
    ];

    const remainingBalls = [];
    const scale = 0.005;
    const rotationX = 0;
    const rotationZ = Math.PI;
    const whiteBallY = 2.57;

    balls.scene.traverse((child) => {
      if (!child.isMesh) return;

      child.castShadow = true;
      child.receiveShadow = true;

      child.scale.set(scale, scale, scale);
      this.scene.add(child);

      this.balls.allBalls.push(child);

      // mapping
      for (const [key, assign] of ballMap) {
        if (child.name.includes(key)) {
          child.userData.name = key;
          assign(child);
        }
      }

      // INITIAL ROTATION (only once)
      this.rotateBall(child, rotationX, Math.PI, rotationZ);

      if (child.name.includes("whiteBall")) {
        child.position.set(0, whiteBallY, 2);
      } else {
        remainingBalls.push(child);
      }
    });

    this.arrangeBallsInTriangle(remainingBalls);
    this.createBallPhysicsBodies();
    this.createHoleColliders();
    this.initBallsStoppedChannel();

    console.log("%cBalls loaded successfully", "color: red");
  }

  rotateBall(ballMesh, rotationX = 0, rotationY = 0, rotationZ = 0) {
    if (!ballMesh) return;

    // Apply rotation to mesh
    ballMesh.rotation.x = rotationX;
    ballMesh.rotation.y = rotationY;
    ballMesh.rotation.z = rotationZ;
    ballMesh.updateMatrixWorld(true);

    // Apply rotation to physics body if exists
    const body = this.physicsBodies.get(ballMesh);
    if (body) {
      const euler = new THREE.Euler(rotationX, rotationY, rotationZ);
      body.quaternion.setFromEuler(euler);
    }
  }

  initBallsStoppedChannel() {
    const channel = {
      listeners: [],
      subscribe: (callback) => {
        channel.listeners.push(callback);
        return () => {
          channel.listeners = channel.listeners.filter((cb) => cb !== callback);
        };
      },
      emit: (data) => {
        channel.listeners.forEach((callback) => callback(data));
      },
    };
    this.channels.set("ballsStopped", channel);
    channel.subscribe((data) => {
      console.log(
        "%cAll balls stopped moving!",
        "color: green; font-size: 16px; font-weight: bold",
        data
      );
    });
    this.ballsStoppedCheckEnabled = true;
  }

  checkBallsStopped() {
    if (!this.ballsStoppedCheckEnabled || this.balls.allBalls.length === 0)
      return;
    const velocityThreshold = 0.01;
    const angularVelocityThreshold = 0.01;
    let allStopped = true;
    const ballStates = [];
    for (const ballMesh of this.balls.allBalls) {
      const body = this.physicsBodies.get(ballMesh);
      if (!body) continue;
      const linearVel = body.velocity.length();
      const angularVel = body.angularVelocity.length();
      const isStopped =
        linearVel < velocityThreshold && angularVel < angularVelocityThreshold;
      ballStates.push({
        name: ballMesh.userData.name || ballMesh.name,
        linearVelocity: linearVel,
        angularVelocity: angularVel,
        stopped: isStopped,
      });
      if (!isStopped) {
        allStopped = false;
      }
    }
    if (allStopped) {
      const channel = this.channels.get("ballsStopped");
      if (channel) {
        channel.emit({
          timestamp: Date.now(),
          ballStates: ballStates,
        });
        this.ballsStoppedCheckEnabled = false;
      }
    }
  }

  shootWhiteBall() {
    if (!this.balls.white) return;
    const whiteBallBody = this.physicsBodies.get(this.balls.white);
    if (whiteBallBody) {
      const force = new CANNON.Vec3(0, 0, -40);
      whiteBallBody.applyImpulse(force, whiteBallBody.position);
      this.ballsStoppedCheckEnabled = true;
      console.log("%cWhite ball shot!", "color: yellow");
    }
  }

  createHoleColliders() {
    const holeSize = 0.15;
    const holeDepth = 0.3;
    const tableWidth = 3;
    const tableLength = 6.2;
    const holePositions = [
      { x: -tableWidth / 2 + 0.2, z: -tableLength / 2, name: "corner1" },
      { x: tableWidth / 2 - 0.2, z: -tableLength / 2, name: "corner2" },
      { x: -tableWidth / 2 + 0.2, z: tableLength / 2, name: "corner3" },
      { x: tableWidth / 2 - 0.2, z: tableLength / 2, name: "corner4" },
      { x: -tableWidth / 2, z: 0, name: "side1" },
      { x: tableWidth / 2, z: 0, name: "side2" },
    ];
    const rotationMatrix = new THREE.Matrix4().makeRotationY(Math.PI);
    holePositions.forEach((pos) => {
      const position = new THREE.Vector3(pos.x, 2, pos.z);
      position.applyMatrix4(rotationMatrix);
      const holeShape = new CANNON.Box(
        new CANNON.Vec3(holeSize, holeDepth, holeSize)
      );
      const holeBody = new CANNON.Body({ mass: 0 });
      holeBody.addShape(holeShape);
      holeBody.position.set(position.x, position.y, position.z);
      holeBody.userData = { type: "hole", name: pos.name };
      holeBody.collisionResponse = false;
      this.world.addBody(holeBody);
      this.holeBodies.push(holeBody);
    });
  }

  createBallPhysicsBodies() {
    if (this.balls.allBalls.length === 0) return;
    this.balls.allBalls[0].updateMatrixWorld(true);
    const tempBox = new THREE.Box3().setFromObject(this.balls.allBalls[0]);
    const ballSize = tempBox.getSize(new THREE.Vector3());
    const ballRadius = Math.max(ballSize.x, ballSize.y, ballSize.z) / 2;
    const ballMaterial = new CANNON.Material("ball", {
      friction: 0.2,
      restitution: 1,
    });
    const tableBallContact = new CANNON.ContactMaterial(
      this.tableMaterial,
      ballMaterial,
      {
        friction: 0.14,
        restitution: 0.7,
        contactEquationStiffness: 1e7,
        contactEquationRelaxation: 3,
        frictionEquationStiffness: 1e7,
        frictionEquationRelaxation: 3,
      }
    );
    const ballBallContact = new CANNON.ContactMaterial(
      ballMaterial,
      ballMaterial,
      {
        friction: 0.015,
        restitution: 0.93,
        contactEquationStiffness: 1e7,
        contactEquationRelaxation: 3,
        frictionEquationStiffness: 1e7,
        frictionEquationRelaxation: 3,
      }
    );
    this.world.addContactMaterial(tableBallContact);
    this.world.addContactMaterial(ballBallContact);
    this.balls.allBalls.forEach((ballMesh) => {
      ballMesh.updateMatrixWorld(true);
      const body = new CANNON.Body({ mass: 1 });
      body.material = ballMaterial;
      body.addShape(new CANNON.Sphere(ballRadius));
      body.position.copy(ballMesh.position);
      body.quaternion.copy(ballMesh.quaternion);
      body.linearDamping = 0.7;
      body.angularDamping = 0.3;
      this.world.addBody(body);
      this.physicsBodies.set(ballMesh, body);
    });
  }

  resize() {
    this.sizes.width = window.innerWidth;
    this.sizes.height = window.innerHeight;
    const viewSize = this.viewSize;
    const aspect = this.sizes.width / this.sizes.height;
    this.camera.left = -viewSize * aspect;
    this.camera.right = viewSize * aspect;
    this.camera.top = viewSize;
    this.camera.bottom = -viewSize;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(this.sizes.width, this.sizes.height);
    this.renderer.setPixelRatio(this.sizes.pixelRatio);
  }

  update() {
    const currentTime = Date.now();
    this.time.delta = currentTime - this.time.current;
    this.time.current = currentTime;
    this.world.step(
      this.fixedTimeStep,
      Math.min(this.time.delta / 1000, 0.1),
      this.maxSubSteps
    );
    this.physicsBodies.forEach((body, mesh) => {
      if (mesh instanceof THREE.Mesh) {
        mesh.position.copy(body.position);
        mesh.quaternion.copy(body.quaternion);
      }
    });
    this.checkBallsStopped();
    this.cannonDebugger?.update();
    this.stick?.update();
    this.renderer.render(this.scene, this.camera);
    this.controls?.update();
    window.requestAnimationFrame(() => this.update());
  }
}

class Stick {
  constructor(position, scene, assets, game) {
    this.position = position;
    this.shooting = false;
    this.visible = true;
    this.rotation = 0;
    this.power = 0;
    this.trackMouse = true;
    this.scene = scene;
    this.assets = assets;
    this.game = game;
    this.mousePosition = new THREE.Vector2();
    this.isDragging = false;
    this.dragStartAngle = 0;
    this.dragStartMouse = new THREE.Vector2();
    this.currentAngle = 0;
    this.offset = 0.1;
    this.isInteractingWithInput = false;
    this.init();
  }

  init() {
    this.group = new THREE.Group();
    this.mesh = new THREE.Mesh(
      new THREE.PlaneGeometry(3.8, 0.2),
      new THREE.MeshBasicMaterial({
        map: this.assets.cueStick,
        alphaTest: 0.5,
        side: THREE.DoubleSide,
      })
    );
    this.mesh.rotation.set(-Math.PI / 2, 0, Math.PI / 2);
    const stickLength = 3.8;
    this.mesh.position.set(0, 0, stickLength / 2);
    this.group.add(this.mesh);
    this.group.position.copy(this.position);
    this.scene.add(this.group);
    this.mesh.castShadow = true;
    this.mesh.receiveShadow = true;
    this.currentAngle = 0;
    this.group.rotation.y = this.currentAngle;
    this.setupEvents();
  }

  setupEvents() {
    const handleStart = (clientX, clientY, event) => {
      // Prevent rotation if clicking on input element
      if (event && event.target && event.target.tagName === "INPUT") return;
      if (this.shooting || !this.trackMouse) return;
      this.isDragging = true;
      this.dragStartMouse.set(clientX, clientY);
      this.dragStartAngle = this.currentAngle;
    };
    const handleMove = (clientX, clientY) => {
      if (!this.isDragging || this.shooting) return;
      this.mousePosition.x = (clientX / window.innerWidth) * 2 - 1;
      this.mousePosition.y = -(clientY / window.innerHeight) * 2 + 1;
      this.currentAngle = this.getAngle();
      this.group.rotation.y = this.currentAngle;
    };
    const handleEnd = () => {
      this.isDragging = false;
    };
    window.addEventListener("mousedown", (e) =>
      handleStart(e.clientX, e.clientY, e)
    );
    window.addEventListener("mousemove", (e) => {
      if (this.isDragging) {
        handleMove(e.clientX, e.clientY);
      }
    });
    window.addEventListener("mouseup", handleEnd);
    window.addEventListener(
      "touchstart",
      (e) => {
        // Prevent rotation if touching input element
        if (e.target && e.target.tagName === "INPUT") {
          e.preventDefault();
          return;
        }
        e.preventDefault();
        if (e.touches.length > 0) {
          handleStart(e.touches[0].clientX, e.touches[0].clientY, e);
        }
      },
      { passive: false }
    );
    window.addEventListener(
      "touchmove",
      (e) => {
        e.preventDefault();
        if (this.isDragging && e.touches.length > 0) {
          handleMove(e.touches[0].clientX, e.touches[0].clientY);
        }
      },
      { passive: false }
    );
    window.addEventListener(
      "touchend",
      (e) => {
        e.preventDefault();
        handleEnd();
      },
      { passive: false }
    );
  }

  addAimLine() {
    this.aimLine = new THREE.Line3(this.position, new THREE.Vector3(0, 0, 2));
    this.scene.add(this.aimLine);
  }

  getAngle() {
    if (!this.group || !this.game.camera) return 0;
    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(this.mousePosition, this.game.camera);
    const plane = new THREE.Plane(
      new THREE.Vector3(0, 1, 0),
      -this.group.position.y
    );
    const intersectionPoint = new THREE.Vector3();
    raycaster.ray.intersectPlane(plane, intersectionPoint);
    const direction = new THREE.Vector3()
      .subVectors(intersectionPoint, this.group.position)
      .normalize();
    return Math.atan2(-direction.x, -direction.z);
  }

  shootBall() {
    if (!this.game.balls.white || this.shooting) return;
    const whiteBallBody = this.game.physicsBodies.get(this.game.balls.white);
    if (!whiteBallBody) return;
    const forceMagnitude = this.offset * 40;
    const forceX = -Math.sin(this.currentAngle) * forceMagnitude;
    const forceZ = -Math.cos(this.currentAngle) * forceMagnitude;
    const force = new CANNON.Vec3(forceX, 0, forceZ);
    whiteBallBody.applyForce(force, whiteBallBody.position);

    this.shooting = true;
    this.game.ballsStoppedCheckEnabled = true;
    console.log(
      "%cBall shot with force:",
      "color: pink",
      forceMagnitude,
      "direction:",
      this.currentAngle
    );
  }

  update() {
    if (this.shooting) {
      this.group.visible = false;
    }
    console.log(this.offset);
    if (this.game.balls.white) {
      const whiteBallPos = this.game.balls.white.position;
      const offsetX = Math.sin(this.currentAngle) * this.offset;
      const offsetZ = Math.cos(this.currentAngle) * this.offset;
      this.group.position.set(
        whiteBallPos.x + offsetX,
        this.group.position.y,
        whiteBallPos.z + offsetZ
      );
    }
  }
}

window.onload = () => {
  const game = new Game(sources);
  document.documentElement.classList.add("js");
  const inputElement = document.querySelector('input[type="range"]');

  if (inputElement) {
    const handleInputEnd = () => {
      if (game.stick.isInteractingWithInput) {
        game.stick.shootBall();

        console.log("ball shot");
        inputElement.value = 0;
        inputElement.parentNode.style.setProperty("--val", 0);
        game.stick.offset = 0.05;
        game.stick.trackMouse = true;
        game.stick.isInteractingWithInput = false;
      }
    };

    inputElement.addEventListener("mousedown", () => {
      game.stick.trackMouse = false;
      game.stick.isInteractingWithInput = true;
    });
    inputElement.addEventListener("mouseup", () => {
      handleInputEnd();
    });
    inputElement.addEventListener("mouseleave", () => {
      handleInputEnd();
    });
    inputElement.addEventListener("touchstart", () => {
      game.stick.trackMouse = false;
      game.stick.isInteractingWithInput = true;
    });
    inputElement.addEventListener("touchend", () => {
      handleInputEnd();
    });
    inputElement.addEventListener("touchcancel", () => {
      handleInputEnd();
    });

    // Handle case where user releases mouse outside the input element
    window.addEventListener("mouseup", () => {
      handleInputEnd();
    });
    window.addEventListener("touchend", () => {
      handleInputEnd();
    });
  }

  document.addEventListener(
    "input",
    (e) => {
      let _t = e.target;
      _t.parentNode.style.setProperty("--val", +_t.value);
      // Map input value (0-100) to offset range (0.05 to 1.0)
      const inputValue = +_t.value;
      game.stick.offset = 0.05 + (inputValue / 100) * 0.95;
    },
    false
  );
};

The physics is behaving a bit wierd - don’t know if it’s because of the trimesh for the table or i have some issue with my logic and implementation. The shooting and other things are working fine but sometimes the balls just wierdly behave.
Can anyone help and fix this out and help in this ??

Trimeshes can have weird rolling behavior when spheres cross the triangle boundaries.. can you replace the tabletop with a flat box instead?

By doing that, i can not pot the ball inside as the cushion is a complete mesh.
Tried creating Trimesh using the three-to-cannon lib also, had more worse results with that.

Yeah… well using a trimesh gets you a diagonal edge across the table, and spheres will occasionally stutter when crossing that edge. :slight_smile: I haven’t really found a better solution than using a box primitive(s) in that case. You can programatically detect when a ball nears one of the pockets, and make a determination based on the ball velocity/position whether it should fall in, then animate the fall programatically.

Many/most pool simulations just do the sim entirely in 2d and just render them in 3d. The more advanced simulators (VirtualPool) are pretty rare, and aren’t using off the shelf physics engines, afaik.. they are using completely custom engines tailored to the problem domain.

1 Like

I tried using pixijs and circle colliders on it, and rendering the 3d object canvas over it. And it actually worked. Thank you for giving a scope.

2 Likes