472 lines
11 KiB
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>
|