Trajectory prediction for “Mikan Pile” (aim assist)

2025-12-22~7 min read

Motivation: show the path before throwing

  • Visually show how the mikan will fly before the throw
    • Makes it easier to adjust direction and power
    • Improves overall control feel

Approach: simple ballistic approximation

Use a lightweight prediction based on initial velocity and gravity to sample points along time. Render the sampled points as a line/markers in three.js.

  • Sample several timesteps ahead (e.g., 30–60 points)
  • Convert joystick input to launch direction and magnitude
  • Render a preview line that updates as the player adjusts input

Implementation Code

private predictTrajectory(
  start: THREE.Vector3,
  initialVelocity: THREE.Vector3,
  gravityY: number
): { points: THREE.Vector3[]; end: THREE.Vector3; endTangent: THREE.Vector3 } {
  // Height where the ball touches the plate top
  const plateTopY = GameParams.plate.height / 2;
  const targetY = plateTopY + GameParams.ball.radius;

  // Sampling settings (lightweight for visual purposes)
  const dt = 1 / 30; // 30fps equivalent
  const maxSteps = 120; // Max 4 seconds

  const g = new THREE.Vector3(0, gravityY, 0);
  const points: THREE.Vector3[] = [];
  let prev = start.clone();
  points.push(prev.clone());

  for (let i = 1; i <= maxSteps; i++) {
    const t = dt * i;
    // p(t) = p0 + v0*t + 0.5*g*t^2
    const p = start
      .clone()
      .add(initialVelocity.clone().multiplyScalar(t))
      .add(g.clone().multiplyScalar(0.5 * t * t));

    points.push(p);

    // If crossing targetY downward, use linear interpolation for intersection
    if (prev.y >= targetY && p.y <= targetY) {
      const denom = prev.y - p.y;
      const alpha = denom !== 0 ? (prev.y - targetY) / denom : 1;
      const hit = prev.clone().lerp(p, alpha);
      points[points.length - 1] = hit;
      break;
    }

    prev = p;
  }

  const end = points[points.length - 1].clone();
  const endTangent = points.length >= 2
    ? end.clone().sub(points[points.length - 2]).normalize()
    : new THREE.Vector3(0, 0, -1);

  return { points, end, endTangent };
}

Visual Preview Rendering

  • Render trajectory line
    • Use three.js Line object to connect calculated points
    • Change line color based on mikan color (orange or green)
  • Display arrow tip
    • Show a cone-shaped arrow at the trajectory end point indicating direction
    • Calculate trajectory tangent to determine arrow orientation
  • Preview update timing
    • Recalculate and update trajectory display whenever joystick position changes
    • Also update preview at regular intervals when camera rotates

Trajectory Line Rendering Implementation

private buildTrajectoryLine(
  points: THREE.Vector3[],
  color: number
): THREE.Line {
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({
    color,
    transparent: true,
    opacity: 0.85,
    depthTest: true,
  });
  const line = new THREE.Line(geometry, material);
  line.name = 'TrajectoryLine';
  return line;
}

// Arrow tip (cone) rendering
private buildArrowHead(
  tipPosition: THREE.Vector3,
  direction: THREE.Vector3,
  color: number
): THREE.Mesh {
  const headLength = 0.22;
  const headRadius = 0.08;
  const geometry = new THREE.ConeGeometry(headRadius, headLength, 16);
  const material = new THREE.MeshStandardMaterial({
    color,
    metalness: 0.2,
    roughness: 0.6,
    transparent: true,
    opacity: 0.95,
  });
  const cone = new THREE.Mesh(geometry, material);
  
  // Set direction
  const q = new THREE.Quaternion().setFromUnitVectors(
    new THREE.Vector3(0, 1, 0),
    direction.normalize()
  );
  cone.quaternion.copy(q);
  cone.position.copy(tipPosition);
  
  return cone;
}

UX notes

  • Keep the preview subtle (low opacity / thin line) so it doesn’t distract
  • Clamp max power to keep prediction stable and readable