popper/src/components/GameField.vue

472 lines
11 KiB
Vue

<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>