<template>
  <div
    class="game"
    ref="fieldRef"
    :style="{ width: width + 'px', height: height + 'px' }"
  >
    <div class="game-field">
      <GameObjects :field="field" :radius="radius" />
    </div>

    <Bubble v-if="next" :bubble="next" :radius="radius" />
    <Bubble v-if="traversing" :bubble="traversing" :radius="radius" />

    <Shooter v-if="next" :center="next" :radius="radius" :angle="angle" />
    <ScoreDisplay
      :score="score"
      :hiscore="hiscore"
      :shots-left="shotsLeft"
      :streak="streak"
      :max-streak="maxStreak"
    />
    <GameOver v-if="gameOver" :score="score" :streak="maxStreak">{{
      victory ? 'You won!' : 'Game over.'
    }}</GameOver>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { BubbleType } from '../lib/types';
import GameObjects from './GameObjects.vue';
import Bubble from './Bubble.vue';
import { computed } from '@vue/reactivity';
import Shooter from './Shooter.vue';
import GameOver from './GameOver.vue';
import ScoreDisplay from './ScoreDisplay.vue';

const width = 612;
const height = 864;

const fieldRef = ref<HTMLDivElement>();
const field = ref<BubbleType[]>([]);
const radius = ref(32);
const diameter = computed(() => radius.value * 2);
const colors = ref(5);
const angle = ref(90);
const streak = ref(0);
const maxStreak = ref(0);
const shotsLeft = ref(4);
const offset = ref(0);
const score = ref(0);
const hiscore = ref(0);
const fitment = computed(() => Math.floor(width / diameter.value));

const gameOver = ref(false);
const victory = ref(false);
const next = ref<BubbleType | null>(null);
const traversing = ref<BubbleType | null>(null);
const motionVector = ref({ x: 0, y: 0 });

const bubblePoint = {
  x: width / 2,
  y: height - diameter.value,
};

function rand(min: number, max: number) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

function createRow(column: number, generate = false) {
  const padStart = (column + offset.value) % 2 === 0 ? radius.value : 0;
  const entries = Array.from({ length: fitment.value }, (_, k) => ({
    color: rand(1, colors.value),
    x: padStart + k * diameter.value,
    y: column * (diameter.value - 8),
  }));

  generate ? field.value.push(...entries) : field.value.unshift(...entries);
}

function checkHeightLoss() {
  const lowestPart = field.value.reduce<number>(
    (last, current) => (last < current.y ? current.y : last),
    0
  );

  if (lowestPart > height - diameter.value * 2) {
    gameOver.value = true;
  }
}

function saveHiscore(score: number) {
  const currentHigh = Number(localStorage.getItem('highscore'));
  if ((currentHigh && currentHigh < score) || !currentHigh) {
    localStorage.setItem('highscore', score.toString());
    hiscore.value = score;
  }
}

function addRow() {
  field.value.forEach((item) => {
    item.y += diameter.value - 8;
  });

  offset.value += 1;

  createRow(0);
  checkHeightLoss();
}

function generate() {
  for (let column = 0; column < 5; column++) {
    createRow(column, true);
  }
}

function reset() {
  if (traversing.value) return;
  field.value = [];
  offset.value = 0;
  generate();
  saveHiscore(score.value);
  score.value = 0;
  gameOver.value = false;
  victory.value = false;
  streak.value = 0;
  maxStreak.value = 0;
  determineNext();
}

function determineNext() {
  const usedColors = field.value
    .filter((x) => !x.exiting)
    .reduce<number[]>(
      (list, current) =>
        list.includes(current.color) ? list : [...list, current.color],
      []
    );
  next.value = {
    color: usedColors[rand(0, usedColors.length - 1)],
    x: bubblePoint.x - radius.value,
    y: bubblePoint.y - radius.value,
  };
}

function intersection(
  x0: number,
  y0: number,
  r0: number,
  x1: number,
  y1: number,
  r1: number
) {
  var a, dx, dy, d, h, rx, ry;
  var x2, y2;

  /* dx and dy are the vertical and horizontal distances between
   * the circle centers.
   */
  dx = x1 - x0;
  dy = y1 - y0;

  /* Determine the straight-line distance between the centers. */
  d = Math.sqrt(dy * dy + dx * dx);

  /* Check for solvability. */
  if (d > r0 + r1) {
    /* no solution. circles do not intersect. */
    return false;
  }
  if (d < Math.abs(r0 - r1)) {
    /* no solution. one circle is contained in the other */
    return false;
  }

  /* 'point 2' is the point where the line through the circle
   * intersection points crosses the line between the circle
   * centers.
   */

  /* Determine the distance from point 0 to point 2. */
  a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d);

  /* Determine the coordinates of point 2. */
  x2 = x0 + (dx * a) / d;
  y2 = y0 + (dy * a) / d;

  /* Determine the distance from point 2 to either of the
   * intersection points.
   */
  h = Math.sqrt(r0 * r0 - a * a);

  /* Now determine the offsets of the intersection points from
   * point 2.
   */
  rx = -dy * (h / d);
  ry = dx * (h / d);

  /* Determine the absolute intersection points. */
  var xi = x2 + rx;
  var xi_prime = x2 - rx;
  var yi = y2 + ry;
  var yi_prime = y2 - ry;

  return [xi, xi_prime, yi, yi_prime];
}

function hexagonalGridSnap(item: { x: number; y: number }): {
  x: number;
  y: number;
} {
  const heightConstant = diameter.value - 8;
  const height =
    Math.floor((item.y + radius.value) / heightConstant) * heightConstant;

  const layer = Math.floor((height + radius.value) / heightConstant);
  const layerOffset = (layer + offset.value) % 2 === 0 ? radius.value : 0;

  return {
    x:
      Math.floor((item.x + radius.value - layerOffset) / diameter.value) *
        diameter.value +
      layerOffset,
    y: height,
  };
}

function determineImmediateNeighbors(item: BubbleType) {
  let neighbors: BubbleType[] = [];
  for (const entry of field.value) {
    if (entry.exiting) continue;
    if (entry.x === item.x && entry.y === item.y) continue;
    const intersects = intersection(
      entry.x + radius.value,
      entry.y + radius.value,
      radius.value,
      item.x + radius.value,
      item.y + radius.value,
      diameter.value
    );
    if (intersects) {
      neighbors.push(entry);
    }
  }
  return neighbors;
}

function accountForAllCeilingAttached() {
  let accountedFor: BubbleType[] = [];

  const startingPoint = field.value.filter(
    (item) => item.y === 0 && !item.exiting
  );
  const accountNeighbors = (item: BubbleType) => {
    if (!accountedFor.includes(item)) {
      accountedFor.push(item);
      const neighbors = determineImmediateNeighbors(item);
      if (neighbors.length) {
        for (const neighbor of neighbors) {
          if (accountedFor.includes(neighbor)) continue;
          accountNeighbors(neighbor);
        }
      }
    }
  };
  for (const item of startingPoint) {
    accountNeighbors(item);
  }
  return accountedFor;
}

function checkForPops(item: BubbleType, friendlies: BubbleType[] = []) {
  const neighbors = determineImmediateNeighbors(item);
  const same = neighbors
    .filter(({ color }) => color === item.color)
    .filter((item) => !friendlies.includes(item));

  friendlies.push(...same);

  if (same.length) {
    for (const nextOne of same) {
      checkForPops(nextOne, friendlies);
    }
  }

  return friendlies;
}

function animateRemovals() {
  const toRemove = field.value.filter((item) => item.exiting);
  if (!toRemove.length) return;

  let removeFromField: BubbleType[] = [];
  toRemove.forEach((item) => {
    item.y += 16;
    item.x += rand(-0.001, 0.001);
    if (item.y > height) {
      removeFromField.push(item);
    }
  });

  if (removeFromField.length) {
    field.value = field.value.filter((item) => !removeFromField.includes(item));
  }

  requestAnimationFrame(animateRemovals);
}

function determineIsolated() {
  const accountedFor = accountForAllCeilingAttached();

  let fallCount = 0;
  field.value
    .filter((item) => !accountedFor.includes(item))
    .forEach((i) => {
      i.exiting = true;
      fallCount++;
    });

  score.value += fallCount * 10;

  if (fallCount > 0) animateRemovals();
  return fallCount;
}

function stopDead() {
  if (!traversing.value) return;

  const item = {
    ...traversing.value,
    ...hexagonalGridSnap(traversing.value),
  };

  field.value.push(item);
  traversing.value = null;

  const chain = checkForPops(item);
  if (chain.length >= 3) {
    score.value += chain.length;
    field.value = field.value.filter((item) => !chain.includes(item));
    const dropped = determineIsolated();
    streak.value += chain.length + dropped;

    if (streak.value > maxStreak.value) {
      maxStreak.value = streak.value;
    }
  } else {
    shotsLeft.value -= 1;
    streak.value = 0;
  }

  if (shotsLeft.value < 1) {
    addRow();
    shotsLeft.value = 4;
  }

  determineNext();

  if (!field.value.filter((x) => !x.exiting).length) {
    victory.value = true;
    gameOver.value = true;
    return;
  }

  checkHeightLoss();
}

let steps = 0;
let paused = false;
function move() {
  if (!traversing.value || paused) return;

  for (const entry of field.value) {
    if (entry.exiting) continue;
    const intersects = intersection(
      entry.x + radius.value,
      entry.y + radius.value,
      radius.value - 1,
      traversing.value.x + radius.value,
      traversing.value.y + radius.value,
      radius.value - 1
    );

    if (intersects) {
      stopDead();
      return;
    }
  }

  if (traversing.value.x + diameter.value >= width) {
    motionVector.value.x = motionVector.value.x * -1;
  }

  if (traversing.value.x <= 0) {
    motionVector.value.x = motionVector.value.x * -1;
  }

  if (traversing.value.y <= 0) {
    stopDead();
    return;
  }

  traversing.value.x += motionVector.value.x * 16;
  traversing.value.y += motionVector.value.y * 16;

  if (steps++ > 5000) {
    traversing.value = null;
    determineNext();
    return;
  }

  requestAnimationFrame(move);
}

function startMoving() {
  steps = 0;
  traversing.value = { ...next.value! };
  requestAnimationFrame(move);
}

function normalize(vector: { x: number; y: number }) {
  const length = Math.sqrt(vector.x * vector.x + vector.y * vector.y);
  return {
    x: vector.x / length,
    y: vector.y / length,
  };
}

onMounted(() => {
  generate();
  determineNext();

  const score = Number(localStorage.getItem('highscore'));
  if (score) {
    hiscore.value = score;
  }

  function determineVectors(e: MouseEvent) {
    const x = e.clientX - fieldRef.value!.offsetLeft;
    const y = e.clientY - fieldRef.value!.offsetTop;

    if (traversing.value) return;

    motionVector.value = normalize({
      x: x - bubblePoint.x,
      y: y - bubblePoint.y,
    });

    angle.value =
      ((Math.atan2(motionVector.value.y, motionVector.value.x) -
        Math.atan2(-1, 0)) *
        180) /
      Math.PI;
  }

  fieldRef.value?.addEventListener('click', (e) => {
    if (gameOver.value) {
      reset();
      return;
    }
    if (traversing.value) return;
    if (angle.value < -80 || angle.value > 80) return;
    determineVectors(e);
    startMoving();
  });

  fieldRef.value?.addEventListener('pointermove', (e) => {
    determineVectors(e);
  });
});
</script>
