initial game

This commit is contained in:
Evert Prants 2022-10-19 19:22:53 +03:00
commit 9fb044eac9
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 2582 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popper</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1793
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "popper",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.37"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.1.0",
"prettier": "^2.7.1",
"sass": "^1.55.0",
"typescript": "^4.6.4",
"vite": "^3.1.0",
"vue-tsc": "^0.40.4"
}
}

7
src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import GameField from './components/GameField.vue';
</script>
<template>
<GameField />
</template>

21
src/components/Bubble.vue Normal file
View File

@ -0,0 +1,21 @@
<template>
<div
class="game-object"
:class="{ ['game-object--color-' + bubble.color]: true }"
:style="{
left: bubble.x + 'px',
top: bubble.y + 'px',
width: radius * 2 + 'px',
height: radius * 2 + 'px',
}"
></div>
</template>
<script setup lang="ts">
import { BubbleType } from '../lib/types';
const props = defineProps<{
bubble: BubbleType;
radius: number;
}>();
</script>

View File

@ -0,0 +1,434 @@
<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" />
<GameOver v-if="gameOver" :score="score">{{
victory ? 'You won!' : 'Game over.'
}}</GameOver>
</div>
</template>
<script setup lang="ts">
import { nextTick, 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';
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 shotsLeft = ref(4);
const offset = ref(0);
const score = 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 addRow() {
field.value.forEach((item) => {
item.y += diameter.value - 8;
});
offset.value += 1;
createRow(0);
const lowestPart = field.value.reduce<number>(
(last, current) => (last < current.y ? current.y : last),
0
);
if (lowestPart > height - diameter.value * 2) {
gameOver.value = true;
alert('Game over!');
}
}
function generate() {
for (let column = 0; column < 5; column++) {
createRow(column, true);
}
}
function reset() {
if (traversing.value) return;
field.value = [];
offset.value = 0;
generate();
gameOver.value = false;
victory.value = false;
}
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 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 findIsolatedGroups(groups: BubbleType[][] = []) {
for (const item of field.value) {
if (groups.some((group) => group.includes(item))) {
continue;
}
const neighbors = determineImmediateNeighbors(item);
if (neighbors.length) {
let setgroup;
for (const neighbor of neighbors) {
if (groups.some((group) => group.includes(neighbor))) {
setgroup = groups.find((item) => item.includes(neighbor));
break;
}
}
if (setgroup) {
setgroup.push(item);
} else {
groups.push([item, ...neighbors]);
}
} else {
groups.push([item]);
}
}
return groups;
}
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 groups = findIsolatedGroups([]);
let failedGroups = groups.filter(
(group) => !group.some((item) => item.y === 0)
);
if (failedGroups.length) {
let fallCount = 0;
field.value
.filter((item) => failedGroups.some((group) => group.includes(item)))
.forEach((i) => {
i.exiting = true;
fallCount++;
});
score.value += fallCount * 10;
}
animateRemovals();
}
function stopDead() {
if (!traversing.value) return;
const item = {
...traversing.value,
...hexagonalGridSnap(traversing.value),
};
field.value.push(item);
traversing.value = null;
determineNext();
const chain = checkForPops(item);
if (chain.length >= 3) {
score.value += chain.length;
field.value = field.value.filter((item) => !chain.includes(item));
determineIsolated();
} else {
shotsLeft.value -= 1;
}
if (shotsLeft.value < 1) {
addRow();
shotsLeft.value = 4;
}
if (!field.value.length) {
victory.value = true;
gameOver.value = true;
}
}
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();
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>

View File

@ -0,0 +1,18 @@
<template>
<Bubble
v-for="bubble in field"
:key="bubble.x + '-' + bubble.y"
:bubble="bubble"
:radius="radius"
/>
</template>
<script setup lang="ts">
import { BubbleType } from '../lib/types';
import Bubble from './Bubble.vue';
const props = defineProps<{
field: BubbleType[];
radius: number;
}>();
</script>

View File

@ -0,0 +1,11 @@
<template>
<div class="game-over">
<h2><slot></slot></h2>
<p>Score: {{ score }}</p>
<small>Click to restart</small>
</div>
</template>
<script setup lang="ts">
defineProps<{ score: number }>();
</script>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Install
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
in your IDE for a better DX
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div
class="game-pointer"
ref="pointerRef"
:style="{
transform: `rotate(${angle}deg)`,
left: center.x + radius - 50 + 'px',
top: center.y + radius - 50 + 'px',
}"
>
<div class="game-pointer-arrow"></div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BubbleType } from '../lib/types';
const pointerRef = ref<HTMLDivElement>();
const props = defineProps<{
center: BubbleType;
angle: number;
radius: number;
}>();
</script>

6
src/lib/types.ts Normal file
View File

@ -0,0 +1,6 @@
export interface BubbleType {
color: number;
x: number;
y: number;
exiting?: boolean;
}

5
src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.scss'
import App from './App.vue'
createApp(App).mount('#app')

104
src/style.scss Normal file
View File

@ -0,0 +1,104 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
#app {
width: 100%;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
.game {
background-color: #ddd;
position: relative;
overflow: hidden;
margin: auto;
&-pointer {
position: absolute;
width: 100px;
height: 100px;
&-arrow {
position: absolute;
left: calc(50% - 10px);
top: -10px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid black;
}
}
&-over {
position: absolute;
background-color: rgb(0 0 0 / 62%);
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
font-size: 2rem;
pointer-events: none;
}
&-object {
position: absolute;
border-radius: 100%;
&--color {
&-1 {
background-color: #d45d5d;
}
&-2 {
background-color: #12b944;
}
&-3 {
background-color: #1574b4;
}
&-4 {
background-color: #bc3fe2;
}
&-5 {
background-color: #3fc1e2;
}
}
}
}

7
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})