├── src ├── keyboard.js ├── inventory.js ├── create-element.js ├── svg-utils.js ├── shuffle.js ├── weighted-random.js ├── menu-background.js ├── vector.js ├── colors.js ├── demo-colors.js ├── ox-emoji.js ├── fish-emoji.js ├── remove-path.js ├── goat-emoji.js ├── modified-kontra │ ├── game-loop.js │ ├── updatable.js │ └── game-object.js ├── goat-farm.js ├── hull.js ├── ox-farm.js ├── fish-farm.js ├── cell.js ├── tree.js ├── svg.js ├── pond.js ├── find-route.js ├── grid.js ├── grid-toggle.js ├── fish.js ├── goat.js ├── animal.js ├── ox.js ├── menu.js ├── yurt.js ├── layers.js ├── audio.js ├── gameover.js ├── person.js ├── pointer.js ├── path.js ├── farm.js ├── ui.js └── main.js ├── .gitignore ├── dist ├── game.zip └── index.html ├── screenshot-bigx1.png ├── screenshot-bigx2.png ├── screenshot-smallx1.png ├── screenshot-smallx2.png ├── index.html ├── LICENSE ├── package.json ├── vite.config.js ├── .eslintrc.cjs ├── README.md └── plugins └── vite-js13k.js /src/keyboard.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist/minified.js 3 | -------------------------------------------------------------------------------- /src/inventory.js: -------------------------------------------------------------------------------- 1 | export const inventory = { 2 | paths: 18, 3 | }; 4 | -------------------------------------------------------------------------------- /dist/game.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burntcustard/tiny-yurts/HEAD/dist/game.zip -------------------------------------------------------------------------------- /screenshot-bigx1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burntcustard/tiny-yurts/HEAD/screenshot-bigx1.png -------------------------------------------------------------------------------- /screenshot-bigx2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burntcustard/tiny-yurts/HEAD/screenshot-bigx2.png -------------------------------------------------------------------------------- /screenshot-smallx1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burntcustard/tiny-yurts/HEAD/screenshot-smallx1.png -------------------------------------------------------------------------------- /screenshot-smallx2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burntcustard/tiny-yurts/HEAD/screenshot-smallx2.png -------------------------------------------------------------------------------- /src/create-element.js: -------------------------------------------------------------------------------- 1 | export const createElement = (tag = 'div') => document.createElement(tag); 2 | -------------------------------------------------------------------------------- /src/svg-utils.js: -------------------------------------------------------------------------------- 1 | export const createSvgElement = (tag = 'svg') => document.createElementNS('http://www.w3.org/2000/svg', tag); 2 | -------------------------------------------------------------------------------- /src/shuffle.js: -------------------------------------------------------------------------------- 1 | export const shuffle = (array) => array 2 | .map((value) => ({ value, sort: Math.random() })) 3 | .sort((a, b) => a.sort - b.sort) 4 | .map(({ value }) => value); 5 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/weighted-random.js: -------------------------------------------------------------------------------- 1 | export const weightedRandom = (weights) => { 2 | const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); 3 | const randomValue = Math.random() * totalWeight; 4 | let cumulativeWeight = 0; 5 | 6 | for (let i = 0; i < weights.length; i++) { 7 | cumulativeWeight += weights[i]; 8 | 9 | if (randomValue < cumulativeWeight) { 10 | return i; 11 | } 12 | } 13 | 14 | return undefined; 15 | }; 16 | -------------------------------------------------------------------------------- /src/menu-background.js: -------------------------------------------------------------------------------- 1 | import { createElement } from './create-element'; 2 | 3 | export const menuBackground = createElement(); 4 | 5 | // This has to be a sibling element, behind the gameoverScreen, not a child of it, 6 | // so that the backdrop-filter can transition properly 7 | menuBackground.style.cssText = ` 8 | backdrop-filter: blur(8px); 9 | position: absolute; 10 | inset: 0; 11 | pointer-events: none; 12 | background: #fffb; 13 | `; 14 | 15 | export const initMenuBackground = () => { 16 | document.body.append(menuBackground); 17 | menuBackground.style.opacity = 0; 18 | }; 19 | -------------------------------------------------------------------------------- /src/vector.js: -------------------------------------------------------------------------------- 1 | import { Vector } from 'kontra'; 2 | 3 | /** 4 | * Extra vector maths, to work alongside the Vector Kontra.js object 5 | */ 6 | 7 | export const rotateVector = (vector, angle) => new Vector({ 8 | x: vector.x * Math.cos(angle) - vector.y * Math.sin(angle), 9 | y: vector.x * Math.sin(angle) - vector.y * Math.cos(angle), 10 | }); 11 | 12 | export const combineVectors = (vectorA, vectorB) => { 13 | const magnitude = vectorA.length(); 14 | const result = vectorA.add(vectorB); 15 | const resultMagnitude = result.length(); 16 | const scaledResult = result.scale(magnitude / resultMagnitude); 17 | 18 | return scaledResult; 19 | }; 20 | -------------------------------------------------------------------------------- /src/colors.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable key-spacing */ 2 | 3 | /** 4 | * The colors list is also used as a farm type enum, for example instead of: 5 | * `if (farm.type === 'goat')`, do `if (farm.type === colors.goat)` 6 | */ 7 | export const colors = { 8 | grass: '#8a5', 9 | leaf: '#ac6', 10 | base: '#794', 11 | yurt: '#fff', 12 | path: '#dca', // previously #cb9 13 | ox: '#b75', 14 | oxHorn: '#dee', 15 | goat: '#abb', // previously #abb 16 | fish: '#f80', 17 | black: '#000', 18 | ui: '#443', 19 | red: '#e31', 20 | grid: '#0001', 21 | shade: '#0001', 22 | shade2: '#0002', 23 | gridRed:'#f002', 24 | }; 25 | 26 | export const shadowOpacity = 0.12; 27 | -------------------------------------------------------------------------------- /src/demo-colors.js: -------------------------------------------------------------------------------- 1 | import { colors } from './colors'; 2 | import { createElement } from './create-element'; 3 | 4 | export const demoColors = () => { 5 | const colorTestContainer = createElement('svg'); 6 | colorTestContainer.style.cssText = (`position:absolute;left:8px;bottom:32px;display:grid;gap:8px;`); 7 | document.body.append(colorTestContainer); 8 | Object.entries(colors).forEach(([name, value]) => { 9 | const dot = createElement(); 10 | dot.style.cssText = `display:block;width:16px;height:16px;border-radius:50%;overflow:visible;`; 11 | dot.innerHTML = `${value}: ${name}`;
12 | dot.style.background = value;
13 | colorTestContainer.append(dot);
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/src/ox-emoji.js:
--------------------------------------------------------------------------------
1 | import { createSvgElement } from './svg-utils';
2 | import { colors } from './colors';
3 |
4 | export const emojiOx = () => {
5 | const svg = createSvgElement();
6 | svg.setAttribute('viewBox', '0 0 16 16');
7 | svg.setAttribute('stroke-linecap', 'round');
8 |
9 | const body = createSvgElement('path');
10 | body.setAttribute('fill', colors.ox);
11 | body.setAttribute('d', 'M15 2h-4c-1 0-5 0-6 2l-2 5c-1 2 0 5 2 5h4l2 2z');
12 |
13 | const horn = createSvgElement('path');
14 | horn.setAttribute('fill', colors.oxHorn);
15 | horn.setAttribute('d', 'M12 3c-2 2-5-1-7-1s-3-.5-3-1c0-.5 2-1 4-1s8 1 6 3z');
16 |
17 | const eye = createSvgElement('path');
18 | eye.setAttribute('d', 'm8 6 0 0');
19 | eye.setAttribute('stroke-width', 2);
20 | eye.setAttribute('stroke', colors.ui);
21 |
22 | svg.append(body, horn, eye);
23 |
24 | return svg;
25 | };
26 |
--------------------------------------------------------------------------------
/src/fish-emoji.js:
--------------------------------------------------------------------------------
1 | import { createSvgElement } from './svg-utils';
2 | import { colors } from './colors';
3 |
4 | export const emojiFish = () => {
5 | const svg = createSvgElement();
6 | svg.setAttribute('viewBox', '0 0 20 20');
7 | svg.setAttribute('stroke-linecap', 'round');
8 |
9 | const body = createSvgElement('path');
10 | body.setAttribute('fill', colors.fish);
11 | body.setAttribute('d', 'm17 11 1-4c1-4-5 0-5 4s6 8 5 4zM4 6.5c0-2 2-4 2-4 4 0 7 4 8 8m-11 4c4 2 14 6 6-2');
12 |
13 | const fins = createSvgElement('path');
14 | fins.setAttribute('fill', colors.fish);
15 | fins.setAttribute('d', 'm0 11c0 10 16 4 16 0s-16-12-16 0');
16 |
17 | const eye = createSvgElement('path');
18 | eye.setAttribute('d', 'm4 9 0 0');
19 | eye.setAttribute('stroke-width', 2);
20 | eye.setAttribute('stroke', colors.ui);
21 |
22 | svg.append(fins, body, eye);
23 |
24 | return svg;
25 | };
26 |
--------------------------------------------------------------------------------
/src/remove-path.js:
--------------------------------------------------------------------------------
1 | import { drawPaths, paths } from './path';
2 | import { inventory } from './inventory';
3 | import { pathTilesIndicatorCount } from './ui';
4 | import { playPathDeleteNote } from './audio';
5 |
6 | export const removePath = (x, y) => {
7 | const pathsToRemove = paths.filter((path) => (
8 | (path.points[0].x === x && path.points[0].y === y)
9 | || (path.points[1].x === x && path.points[1].y === y)
10 | ) && (
11 | // Don't remove "fixed" paths i.e. under yurts
12 | !path.points[0].fixed && !path.points[1].fixed
13 | ));
14 |
15 | pathsToRemove.forEach((pathToRemove) => {
16 | if (inventory.paths < 99) {
17 | inventory.paths++;
18 | pathTilesIndicatorCount.innerText = inventory.paths;
19 | }
20 | pathToRemove.remove();
21 | });
22 |
23 | if (pathsToRemove.length) playPathDeleteNote();
24 |
25 | drawPaths({ changedCells: [{ x, y }] });
26 | };
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 John
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tiny-yurts",
3 | "version": "1.0.0",
4 | "type": "module",
5 | "description": "",
6 | "main": "index.js",
7 | "scripts": {
8 | "dev": "vite",
9 | "dev:host": "vite --host",
10 | "build": "vite build",
11 | "lint": "eslint 'src/*.js'",
12 | "lint:fix": "eslint 'src/*.js' --fix",
13 | "deploy": "git subtree push --prefix dist origin gh-pages"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/burntcustard/tiny-yurts.git"
18 | },
19 | "author": "",
20 | "license": "MIT",
21 | "bugs": {
22 | "url": "https://github.com/burntcustard/tiny-yurts/issues"
23 | },
24 | "homepage": "https://github.com/burntcustard/tiny-yurts#readme",
25 | "dependencies": {
26 | "kontra": "^9.0.0",
27 | "rollup-plugin-kontra": "^1.0.1"
28 | },
29 | "devDependencies": {
30 | "advzip-bin": "^2.0.0",
31 | "eslint": "^8.47.0",
32 | "eslint-config-airbnb-base": "^15.0.0",
33 | "eslint-plugin-import": "^2.28.0",
34 | "html-minifier": "^4.0.0",
35 | "jszip": "^3.10.1",
36 | "roadroller": "^2.1.0",
37 | "terser": "^5.19.2",
38 | "vite": "^4.4.9"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { viteJs13k, viteJs13kPre } from './plugins/vite-js13k'
3 | import kontra from 'rollup-plugin-kontra';
4 |
5 | export default defineConfig({
6 | server: {
7 | port: 3000
8 | },
9 | plugins: [
10 | kontra({
11 | gameObject: {
12 | group: true,
13 | ttl: true, // TODO: Figure out exactly what this is needed for
14 | velocity: true,
15 | },
16 | vector: {
17 | angle: true,
18 | distance: true,
19 | normalize: true,
20 | scale: true,
21 | subtract: true,
22 | },
23 | }),
24 | viteJs13kPre(),
25 | viteJs13k(),
26 | ],
27 | build: {
28 | minify: 'terser',
29 | terserOptions: {
30 | toplevel: true,
31 | compress: {
32 | passes: 2,
33 | unsafe: true,
34 | unsafe_arrows: true,
35 | unsafe_comps: true,
36 | unsafe_math: true,
37 | },
38 | mangle: { properties: { keep_quoted: false }},
39 | module: true,
40 | },
41 | assetsInlineLimit: 0,
42 | modulePreload: {
43 | polyfill: false,
44 | },
45 | reportCompressedSize: false,
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: 'airbnb-base',
7 | overrides: [
8 | {
9 | env: {
10 | node: true,
11 | },
12 | files: [
13 | '.eslintrc.{js,cjs}',
14 | ],
15 | parserOptions: {
16 | sourceType: 'script',
17 | },
18 | },
19 | ],
20 | parserOptions: {
21 | ecmaVersion: 'latest',
22 | sourceType: 'module',
23 | },
24 | rules: {
25 | // Named exports rather than default exports allow >1 related item to be exported more cleanly
26 | 'import/prefer-default-export': 'off',
27 |
28 | // Continue is used to skip the current for() loop iteration
29 | 'no-continue': 'off',
30 |
31 | // GameObjects assign themselves to appropriate lists so are not thrown away
32 | 'no-new': 'off',
33 |
34 | 'no-plusplus': 'off',
35 |
36 | // Allow addEventListener without document. - we know what these globals are
37 | 'no-restricted-globals': 'off',
38 |
39 | // No return statement means that undefined is returned
40 | 'no-return-assign': 'off',
41 |
42 | // Prefer single quotes, but allow template literals because it's how we detect/minify CSS
43 | 'quotes': [ 'error', 'single', {
44 | 'allowTemplateLiterals': true,
45 | }],
46 | },
47 | };
48 |
--------------------------------------------------------------------------------
/src/goat-emoji.js:
--------------------------------------------------------------------------------
1 | import { createSvgElement } from './svg-utils';
2 | import { colors } from './colors';
3 |
4 | export const emojiGoat = () => {
5 | const svg = createSvgElement();
6 | svg.setAttribute('viewBox', '0 0 20 20');
7 | svg.setAttribute('stroke-linecap', 'round');
8 | svg.style.width = '48px';
9 | svg.style.height = '48px';
10 |
11 | const body = createSvgElement('path');
12 | body.setAttribute('fill', colors.goat);
13 | body.setAttribute('d', 'M18 12c-2-3-4-8-7-8-4 0-10 5-10 9 0 3 6 3 8 3l2 4z');
14 |
15 | const horn1 = createSvgElement('path');
16 | horn1.setAttribute('fill', '#bcc');
17 | horn1.setAttribute('d', 'M7.4 7.5c-1-4 3.7-6 8-4 1 .4 1 1.3 0 1-3-1-6 1-4 4 1.1 1.6-3.2 2-4-1z');
18 |
19 | const horn2 = createSvgElement('path');
20 | horn2.setAttribute('fill', '#cdd');
21 | horn2.setAttribute('d', 'M6 5.8c-1-4 3.7-6 8-4 1 .4 1 1.3 0 1-3-1-6 1-4 4 1.1 1.6-3.2 2-4-1z');
22 |
23 | const beard = createSvgElement('path');
24 | beard.setAttribute('fill', '#cdd');
25 | beard.setAttribute('d', 'M6 15c0 4-2 5-2 4 0-2-1 0-1-1v-3z');
26 |
27 | const eye = createSvgElement('path');
28 | eye.setAttribute('d', 'm7 9.3 0 0');
29 | eye.setAttribute('stroke-width', 2);
30 | eye.setAttribute('stroke', colors.ui);
31 |
32 | svg.append(horn1, horn2, beard, body, eye);
33 |
34 | return svg;
35 | };
36 |
--------------------------------------------------------------------------------
/src/modified-kontra/game-loop.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This is the Kontra.js GameLoop, with canvas, context, and blurring ripped out
3 | * https://github.com/straker/kontra/blob/main/src/gameLoop.js
4 | */
5 | export function GameLoop({
6 | update,
7 | render,
8 | }) {
9 | // animation variables
10 | let fps = 60;
11 | let accumulator = 0;
12 | let delta = 1e3 / fps; // delta between performance.now timings (in ms)
13 | let step = 1 / fps;
14 | let last, rAF, now, dt, loop;
15 |
16 | /**
17 | * Called every frame of the game loop.
18 | */
19 | function frame() {
20 | rAF = requestAnimationFrame(frame);
21 | now = performance.now();
22 | dt = now - last;
23 | last = now;
24 |
25 | // prevent updating the game with a very large dt if the game
26 | // were to lose focus and then regain focus later
27 | // Commented out, because we're not pausing the game on unfocus!
28 | // if (dt > 1e3) {
29 | // return;
30 | // }
31 |
32 | accumulator += dt;
33 |
34 | while (accumulator >= delta) {
35 | loop.update(step);
36 |
37 | accumulator -= delta;
38 | }
39 |
40 | loop.render();
41 | }
42 |
43 | // game loop object
44 | loop = {
45 | update,
46 | render,
47 | isStopped: true,
48 |
49 | start() {
50 | last = performance.now();
51 | this.isStopped = false;
52 | requestAnimationFrame(frame);
53 | },
54 |
55 | stop() {
56 | this.isStopped = true;
57 | cancelAnimationFrame(rAF);
58 | },
59 | };
60 |
61 | return loop;
62 | }
63 |
--------------------------------------------------------------------------------
/src/modified-kontra/updatable.js:
--------------------------------------------------------------------------------
1 | import { Vector } from 'kontra';
2 |
3 | /**
4 | * This is the Kontra.js Updatable object, which GameObject extends.
5 | * Unfortunately Kontra doesn't export it, so we have it copy-pasted here
6 | * https://github.com/straker/kontra/blob/main/src/updatable.js
7 | *
8 | * Modifications:
9 | * - Syncing property changes (this._pc) from the parent to the child has been removed
10 | */
11 | class Updatable {
12 | constructor(properties) {
13 | return this.init(properties);
14 | }
15 |
16 | init(properties = {}) {
17 | this.position = new Vector();
18 | this.velocity = new Vector();
19 | this.acceleration = new Vector();
20 | this.isAlive = true;
21 | Object.assign(this, properties);
22 | }
23 |
24 | update(dt) {
25 | this.advance(dt);
26 | }
27 |
28 | advance(dt) {
29 | let acceleration = this.acceleration;
30 |
31 | if (dt) {
32 | acceleration = acceleration.scale(dt);
33 | }
34 |
35 | this.velocity = this.velocity.add(acceleration);
36 |
37 | let velocity = this.velocity;
38 |
39 | if (dt) {
40 | velocity = velocity.scale(dt);
41 | }
42 |
43 | this.position = this.position.add(velocity);
44 | this._pc();
45 | }
46 |
47 | get dx() {
48 | return this.velocity.x;
49 | }
50 |
51 | get dy() {
52 | return this.velocity.y;
53 | }
54 |
55 | set dx(value) {
56 | this.velocity.x = value;
57 | }
58 |
59 | set dy(value) {
60 | this.velocity.y = value;
61 | }
62 |
63 | _pc() {}
64 | }
65 |
66 | export default Updatable;
67 |
--------------------------------------------------------------------------------
/src/goat-farm.js:
--------------------------------------------------------------------------------
1 | import { Goat } from './goat';
2 | import { Farm } from './farm';
3 | import { colors } from './colors';
4 |
5 | export const goatFarms = [];
6 |
7 | export class GoatFarm extends Farm {
8 | constructor(properties) {
9 | super({
10 | ...properties,
11 | fenceColor: colors.goat,
12 | });
13 |
14 | this.needyness = 240;
15 | this.type = colors.goat;
16 |
17 | goatFarms.push(this);
18 |
19 | setTimeout(() => this.addAnimal({}), 2000);
20 | setTimeout(() => this.addAnimal({}), 3000);
21 | setTimeout(() => this.addAnimal({ isBaby: (goatFarms.length - 1) % 2 }), 4000);
22 | this.numAnimals = 3;
23 | this.appearing = true;
24 | setTimeout(() => this.appearing = false, 3000);
25 | }
26 |
27 | upgrade() {
28 | this.numAnimals += 1;
29 |
30 | // Cannot upgrade if there are 7 or more goats already
31 | if (this.numAnimals >= 7) {
32 | return false;
33 | }
34 |
35 | // 2 parents and 1 baby each upgrade
36 | for (let i = 0; i < 2; i++) {
37 | setTimeout(() => this.children.filter((c) => !c.isBaby)[i].showLove(), i * 1000);
38 | setTimeout(() => this.children.filter((c) => !c.isBaby)[i].hideLove(), 7000);
39 | if (i) setTimeout(() => this.addAnimal({ isBaby: true }), i * 1000 + 7000);
40 | }
41 |
42 | return true;
43 | }
44 |
45 | addAnimal({ isBaby = false }) {
46 | super.addAnimal(new Goat({
47 | parent: this,
48 | isBaby,
49 | }));
50 | }
51 |
52 | update(gameStarted, updateCount) {
53 | super.update(gameStarted, updateCount);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/hull.js:
--------------------------------------------------------------------------------
1 | // This is approx 50% ChatGPT and needs to be rewritten in my style (without do while!)
2 |
3 | function orientation(p, q, r) {
4 | const val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
5 | if (val === 0) return 0; // Collinear
6 | return val > 0 ? 1 : 2; // Clockwise or Counterclockwise
7 | }
8 |
9 | export const getOutlinePoints = (points) => {
10 | // Find the point with the lowest y-coordinate (and leftmost if ties)
11 | let leftmost = 0;
12 | for (let i = 1; i < points.length; i++) {
13 | if (
14 | points[i].y < points[leftmost].y
15 | || (
16 | points[i].y === points[leftmost].y
17 | && points[i].x < points[leftmost].x
18 | )
19 | ) {
20 | leftmost = i;
21 | }
22 | }
23 |
24 | const hull = [];
25 | let p = leftmost;
26 |
27 | do {
28 | hull.push(points[p]);
29 | let q = (p + 1) % points.length;
30 |
31 | for (let i = 0; i < points.length; i++) {
32 | if (orientation(points[p], points[i], points[q]) === 2) {
33 | q = i;
34 | }
35 | }
36 |
37 | p = q;
38 | } while (p !== leftmost);
39 |
40 | // Now, perform a true right-to-left pass to include points on the underside of the shape
41 | p = leftmost;
42 |
43 | do {
44 | let q = (p - 1 + points.length) % points.length; // Go backward in the array
45 |
46 | for (let i = 0; i < points.length; i++) {
47 | if (orientation(points[p], points[i], points[q]) === 2) {
48 | q = i;
49 | }
50 | }
51 |
52 | p = q;
53 | if (p !== leftmost) {
54 | hull.push(points[p]);
55 | }
56 | } while (p !== leftmost);
57 |
58 | return hull;
59 | };
60 |
--------------------------------------------------------------------------------
/src/ox-farm.js:
--------------------------------------------------------------------------------
1 | import { Ox } from './ox';
2 | import { Farm } from './farm';
3 | import { colors } from './colors';
4 |
5 | export const oxFarms = [];
6 |
7 | export class OxFarm extends Farm {
8 | constructor(properties) {
9 | super({
10 | ...properties,
11 | fenceColor: colors.ox,
12 | });
13 |
14 | this.needyness = 225;
15 | this.type = colors.ox;
16 |
17 | oxFarms.push(this);
18 |
19 | const isBaby = (oxFarms.length - 1) % 2;
20 |
21 | setTimeout(() => this.addAnimal({}), 2000 + properties.delay ?? 0);
22 | setTimeout(() => this.addAnimal({}), 3000 + properties.delay ?? 0);
23 | setTimeout(() => this.addAnimal({ isBaby }), 4000 + properties.delay ?? 0);
24 | this.numAnimals = 3;
25 | this.appearing = true;
26 | setTimeout(() => this.appearing = false, 3000);
27 | }
28 |
29 | upgrade() {
30 | // Cannot upgrade if there are 5 or more oxen already
31 | if (this.numAnimals >= 5) {
32 | return false;
33 | }
34 |
35 | this.numAnimals += 2;
36 |
37 | // 3 parents 2 babies each upgrade
38 | for (let i = 0; i < this.children.filter((c) => !c.isBaby).length; i++) {
39 | setTimeout(() => this.children.filter((c) => !c.isBaby)[i].showLove(), i * 1000);
40 | setTimeout(() => this.children.filter((c) => !c.isBaby)[i].hideLove(), 7000);
41 | if (i) setTimeout(() => this.addAnimal({ isBaby: true }), i * 1000 + 7000);
42 | }
43 |
44 | return true;
45 | }
46 |
47 | addAnimal({ isBaby = false }) {
48 | super.addAnimal(new Ox({
49 | parent: this,
50 | isBaby,
51 | }));
52 | }
53 |
54 | update(gameStarted, updateCount) {
55 | super.update(gameStarted, updateCount);
56 | // So 3 ox = 2 demand per update, 5 ox = 2 demand per update,
57 | // so upgrading doubles the demand(?)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/fish-farm.js:
--------------------------------------------------------------------------------
1 | // import { Ox } from './ox';
2 | import { Farm } from './farm';
3 | import { Fish } from './fish';
4 | import { colors } from './colors';
5 |
6 | export const fishFarms = [];
7 |
8 | export class FishFarm extends Farm {
9 | constructor(properties) {
10 | super({
11 | ...properties,
12 | fenceColor: '#eee',
13 | width: 2,
14 | height: 2,
15 | });
16 |
17 | this.needyness = 1300;
18 | this.type = colors.fish;
19 |
20 | fishFarms.push(this);
21 |
22 | setTimeout(() => this.addAnimal({}), 2000 + (properties.delay ?? 0));
23 | setTimeout(() => this.addAnimal({}), 2500 + (properties.delay ?? 0));
24 | setTimeout(() => this.addAnimal({}), 3000 + (properties.delay ?? 0));
25 | setTimeout(() => this.addAnimal({}), 3500 + (properties.delay ?? 0));
26 | setTimeout(() => this.addAnimal({}), 4000 + (properties.delay ?? 0));
27 | this.numAnimals = 5;
28 | this.appearing = true;
29 | setTimeout(() => this.appearing = false, 3000);
30 | }
31 |
32 | upgrade() {
33 | // Cannot upgrade if there are 9 or more fish already
34 | if (this.numAnimals >= 9) {
35 | return false;
36 | }
37 |
38 | this.numAnimals += 4;
39 |
40 | // 2 parents
41 | for (let i = 0; i < 2; i++) {
42 | setTimeout(() => this.children[i].showLove(), i * 1000);
43 | setTimeout(() => this.children[i].hideLove(), 7000);
44 | }
45 |
46 | // new fish each upgrade
47 | for (let i = 0; i < 4; i++) {
48 | setTimeout(() => this.addAnimal({}), i * 1000 + 7000);
49 | }
50 |
51 | return true;
52 | }
53 |
54 | addAnimal({ isBaby = false }) {
55 | super.addAnimal(new Fish({
56 | parent: this,
57 | isBaby,
58 | }));
59 | }
60 |
61 | update(gameStarted, updateCount) {
62 | super.update(gameStarted, updateCount);
63 | // So 3 ox = 2 demand per update, 5 ox = 2 demand per update,
64 | // so upgrading doubles the demand(?)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/cell.js:
--------------------------------------------------------------------------------
1 | import {
2 | boardWidth, boardOffsetX, boardOffsetY, gridCellSize,
3 | } from './svg';
4 | import { gridPointerLayer } from './layers';
5 |
6 | export const getGridCell = (x, y) => {
7 | const cellSizePx = gridPointerLayer.getBoundingClientRect().width / boardWidth;
8 |
9 | return {
10 | x: Math.floor(x / cellSizePx),
11 | y: Math.floor(y / cellSizePx),
12 | };
13 | };
14 |
15 | export const getBoardCell = (x, y) => {
16 | const cellSizePx = gridPointerLayer.getBoundingClientRect().width / boardWidth;
17 |
18 | return {
19 | x: boardOffsetX + Math.floor(x / cellSizePx),
20 | y: boardOffsetY + Math.floor(y / cellSizePx),
21 | };
22 | };
23 |
24 | export const svgPxToDisplayPx = (x, y) => {
25 | const cellSizePx = gridPointerLayer.getBoundingClientRect().width / boardWidth;
26 |
27 | return {
28 | x: (boardOffsetX + x) * cellSizePx,
29 | y: (boardOffsetY + y) * cellSizePx,
30 | };
31 | };
32 |
33 | export const pointerPxToSvgPx = (x, y) => {
34 | const cellSizePx = gridPointerLayer.getBoundingClientRect().width / boardWidth;
35 | const scale = cellSizePx / gridCellSize;
36 |
37 | return {
38 | x: (boardOffsetX * gridCellSize) + (x / scale),
39 | y: (boardOffsetY * gridCellSize) + (y / scale),
40 | };
41 | };
42 |
43 | export const isPastHalfwayInto = ({ pointer, from, to }) => {
44 | const cellSizePx = gridPointerLayer.getBoundingClientRect().width / boardWidth;
45 | // TODO: convert from display px to svg px to align with cells better
46 | const fuzzyness = 4; // In device px, how closish to half way is required
47 | const xDiff = pointer.x - cellSizePx * (from.x - boardOffsetX + 0.5);
48 | const yDiff = pointer.y - cellSizePx * (from.y - boardOffsetY + 0.5);
49 | const top = to.y - from.y < 0;
50 | const right = to.x - from.x > 0;
51 | const bottom = to.y - from.y > 0;
52 | const left = to.x - from.x < 0;
53 | const xMid = to.x === from.x;
54 | const yMid = to.y === from.y;
55 |
56 | if (top && xMid) return yDiff < -cellSizePx + fuzzyness;
57 | if (top && right) return xDiff - yDiff > cellSizePx * 2 - fuzzyness;
58 | if (yMid && right) return xDiff > cellSizePx - fuzzyness;
59 | if (bottom && right) return xDiff + yDiff > cellSizePx * 2 - fuzzyness;
60 | if (bottom && xMid) return yDiff > cellSizePx - fuzzyness;
61 | if (bottom && left) return xDiff + -yDiff < -cellSizePx * 2 + fuzzyness;
62 | if (yMid && left) return xDiff < -cellSizePx + fuzzyness;
63 | if (top && left) return xDiff + yDiff < -cellSizePx * 2 + fuzzyness;
64 |
65 | // TODO: Maybe remove or swap to void to save space
66 | // false would make more sense than undefined, but undefined gets minified out(?)
67 | return undefined;
68 | };
69 |
--------------------------------------------------------------------------------
/src/modified-kontra/game-object.js:
--------------------------------------------------------------------------------
1 | import Updatable from './updatable';
2 |
3 | /**
4 | * This is the Kontra.js GameObject, with canvas/context and more ripped out
5 | * https://github.com/straker/kontra/blob/main/src/gameObject.js
6 | */
7 | class GameObject extends Updatable {
8 | init({
9 | width = 1,
10 | height = 1,
11 | render = this.draw,
12 | update = this.advance,
13 | children = [],
14 | ...props
15 | }) {
16 | this._c = [];
17 |
18 | super.init({
19 | width,
20 | height,
21 | ...props
22 | });
23 |
24 | this.addChild(children);
25 |
26 | // rf = render function
27 | this._rf = render;
28 |
29 | // uf = update function
30 | this._uf = update;
31 | }
32 |
33 | /**
34 | * Update all children
35 | */
36 | update(dt) {
37 | this._uf(dt);
38 | this.children.map(child => child.update && child.update(dt));
39 | }
40 |
41 | render() {
42 | this._rf();
43 |
44 | let children = this.children;
45 | children.map(child => child.render && child.render());
46 | }
47 |
48 | _pc() {
49 | this.children.map(child => child._pc());
50 | }
51 |
52 | get x() {
53 | return this.position.x;
54 | }
55 |
56 | get y() {
57 | return this.position.y;
58 | }
59 |
60 | set x(value) {
61 | this.position.x = value;
62 |
63 | // pc = property changed
64 | this._pc();
65 | }
66 |
67 | set y(value) {
68 | this.position.y = value;
69 | this._pc();
70 | }
71 |
72 | get width() {
73 | // w = width
74 | return this._w;
75 | }
76 |
77 | set width(value) {
78 | this._w = value;
79 | this._pc();
80 | }
81 |
82 | get height() {
83 | // h = height
84 | return this._h;
85 | }
86 |
87 | set height(value) {
88 | this._h = value;
89 | this._pc();
90 | }
91 |
92 | set children(value) {
93 | this.removeChild(this._c);
94 | this.addChild(value);
95 | }
96 |
97 | get children() {
98 | return this._c;
99 | }
100 |
101 | addChild(...objects) {
102 | objects.flat().map(child => {
103 | this.children.push(child);
104 | child.parent = this;
105 | child._pc = child._pc || noop;
106 | child._pc();
107 | });
108 | }
109 |
110 | // We never remove children, so this has been commented out
111 | // removeChild(...objects) {
112 | // objects.flat().map(child => {
113 | // if (removeFromArray(this.children, child)) {
114 | // child.parent = null;
115 | // child._pc();
116 | // }
117 | // });
118 | // }
119 | }
120 |
121 | export default function factory() {
122 | return new GameObject(...arguments);
123 | }
124 | export { GameObject as GameObjectClass };
125 |
--------------------------------------------------------------------------------
/src/tree.js:
--------------------------------------------------------------------------------
1 | import { Vector } from 'kontra';
2 | import { GameObjectClass } from './modified-kontra/game-object';
3 | import { createSvgElement } from './svg-utils';
4 | import { gridCellSize } from './svg';
5 | import { treeShadowLayer, treeLayer } from './layers';
6 | import { colors } from './colors';
7 | import { playTreeDeleteNote } from './audio';
8 |
9 | export const trees = [];
10 |
11 | export class Tree extends GameObjectClass {
12 | constructor(properties) {
13 | super({ ...properties });
14 |
15 | trees.push(this);
16 | this.dots = [];
17 | this.addToSvg();
18 | }
19 |
20 | addToSvg() {
21 | const minDotGap = 0.5;
22 | const numTrees = Math.random() * 4;
23 | const x = gridCellSize / 2 + this.x * gridCellSize;
24 | const y = gridCellSize / 2 + this.y * gridCellSize;
25 |
26 | this.svgGroup = createSvgElement('g');
27 | this.svgGroup.style.transform = `translate(${x}px,${y}px)`;
28 | treeLayer.append(this.svgGroup);
29 |
30 | this.shadowGroup = createSvgElement('g');
31 | this.shadowGroup.style.transform = `translate(${x}px,${y}px)`;
32 | treeShadowLayer.append(this.shadowGroup);
33 |
34 | for (let i = 0; i < numTrees; i++) {
35 | const size = Math.random() / 2 + 1;
36 | const position = new Vector(Math.random() * 8 - 4, Math.random() * 8 - 4);
37 |
38 | // If this new tree (...branch) is too close to another tree in this cell, just skip it.
39 | // This means that on average, larger trees are less likely to have many siblings
40 | if (this.dots.some((d) => d.position.distance(position) < d.size + size + minDotGap)) {
41 | continue;
42 | }
43 |
44 | this.dots.push({ position, size });
45 |
46 | const circle = createSvgElement('circle');
47 | circle.style.transform = `translate(${position.x}px, ${position.y}px)`;
48 | circle.setAttribute('fill', colors.leaf);
49 | circle.style.transition = `r .4s cubic-bezier(.5, 1.5, .5, 1)`;
50 | setTimeout(() => circle.setAttribute('r', size), 100 * i);
51 |
52 | this.svgGroup.append(circle);
53 |
54 | const shadow = createSvgElement('ellipse');
55 | shadow.setAttribute('rx', 0);
56 | shadow.setAttribute('ry', 0);
57 | shadow.style.opacity = 0;
58 | shadow.style.transform = `translate(${position.x}px,${position.y}px) rotate(45deg)`;
59 | shadow.style.transition = `all .4s cubic-bezier(.5, 1.5, .5, 1)`;
60 | setTimeout(() => {
61 | shadow.setAttribute('rx', size * 1.2);
62 | shadow.setAttribute('ry', size * 0.9);
63 | shadow.style.opacity = 0.1;
64 | shadow.style.transform = `translate(${position.x + size * 0.7}px,${position.y + size * 0.7}px) rotate(45deg)`;
65 | }, 100 * i);
66 | this.shadowGroup.append(shadow);
67 | }
68 | }
69 |
70 | remove() {
71 | // Remove from the SVG
72 | this.svgGroup.remove();
73 | this.shadowGroup.remove();
74 |
75 | for (let i = 0; i < this.dots.length; i++) {
76 | setTimeout(() => playTreeDeleteNote(), i * 100);
77 | }
78 |
79 | // Remove from trees array
80 | trees.splice(trees.findIndex((p) => p === this), 1);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/svg.js:
--------------------------------------------------------------------------------
1 | import { colors } from './colors';
2 | import { createSvgElement } from './svg-utils';
3 | import { createElement } from './create-element';
4 |
5 | export const gridCellSize = 8; // Width & height of a cell, in SVG px
6 |
7 | // Offset of the buildable area inside the game board
8 | // (this could change at the start of the game before zooming out?)
9 | export const boardOffsetX = 3;
10 | export const boardOffsetY = 2;
11 |
12 | // Number of cells making up the width and height of the game board, only including buildable area
13 | export const boardWidth = 20;
14 | export const boardHeight = 10;
15 |
16 | export const boardSvgWidth = boardWidth * gridCellSize;
17 | export const boardSvgHeight = boardHeight * gridCellSize;
18 |
19 | // Number of cells making up the width and height of the game board, including non-buildable area
20 | export const gridWidth = boardOffsetX + boardWidth + boardOffsetX;
21 | export const gridHeight = boardOffsetY + boardHeight + boardOffsetY;
22 |
23 | export const gridSvgWidth = gridWidth * gridCellSize;
24 | export const gridSvgHeight = gridHeight * gridCellSize;
25 |
26 | export const scaledGridLineThickness = 0.5;
27 | export const gridLineThickness = scaledGridLineThickness / 2;
28 |
29 | export const svgContainerElement = createElement();
30 | svgContainerElement.style.cssText = `
31 | position: absolute;
32 | display: grid;
33 | place-items: center;
34 | overflow: hidden;
35 | background: ${colors.grass};
36 | `;
37 | svgContainerElement.style.width = '100vw';
38 | svgContainerElement.style.height = '100vh';
39 | document.body.append(svgContainerElement);
40 |
41 | export const svgHazardLines = createElement();
42 | // Inined grid color (#0001) to use fewer bytes
43 | svgHazardLines.style.cssText = `
44 | position: absolute;
45 | display: grid;
46 | background: repeating-linear-gradient(-55deg, #0001 0 12px, #0000 0 24px);
47 | `;
48 | svgHazardLines.style.width = '100vw';
49 | svgHazardLines.style.height = '100vh';
50 | svgHazardLines.style.opacity = 0;
51 | svgHazardLines.style.willChange = 'opacity';
52 | svgHazardLines.style.transition = 'opacity.3s';
53 |
54 | export const svgHazardLinesRed = createElement();
55 | // Inlined gridRed color (#f002) to save a few bytes
56 | svgHazardLinesRed.style.cssText = `
57 | position: absolute;
58 | display: grid;
59 | background: repeating-linear-gradient(-55deg, #f002 0 12px, #0000 0 24px);
60 | `;
61 | svgHazardLinesRed.style.width = '100vw';
62 | svgHazardLinesRed.style.height = '100vh';
63 | svgHazardLinesRed.style.opacity = 0;
64 | svgHazardLinesRed.style.willChange = 'opacity';
65 | svgHazardLinesRed.style.transition = `opacity .3s`;
66 |
67 | svgContainerElement.append(svgHazardLines, svgHazardLinesRed);
68 |
69 | // Initial SVG element
70 | export const svgElement = createSvgElement();
71 | // touch-action: none is required to prevent default draggness, probably
72 | svgElement.style.cssText = `
73 | position: relative;
74 | display: grid;
75 | touch-action: none;
76 | `;
77 | svgElement.setAttribute('viewBox', `0 0 ${gridSvgWidth} ${gridSvgHeight}`);
78 | svgElement.setAttribute('preserveAspectRatio', 'xMidYMid slice');
79 | svgElement.style.width = '100vw';
80 | svgElement.style.height = '100vh';
81 | svgElement.style.maxHeight = '68vw';
82 | svgElement.style.maxWidth = '200vh';
83 | svgContainerElement.append(svgElement);
84 |
--------------------------------------------------------------------------------
/src/pond.js:
--------------------------------------------------------------------------------
1 | import { createSvgElement } from './svg-utils';
2 | import { getOutlinePoints } from './hull';
3 | import { pondLayer } from './layers';
4 | import { gridCellSize } from './svg';
5 |
6 | export const ponds = [];
7 |
8 | const createPondShape = (width, height) => {
9 | const points = [];
10 |
11 | for (let h = -height / 2 + 0.5; h <= height / 2 - 0.5; h++) {
12 | for (let w = -width / 2 + 0.5; w <= width / 2 - 0.5; w++) {
13 | if (width / 2 - Math.abs(w) + Math.random() * 2 - 1 > Math.abs(h)) {
14 | points.push({ x: Math.floor(w), y: Math.floor(h) });
15 | }
16 | }
17 | }
18 |
19 | // If the number of points in the pond is bigger than 2, i.e. it's not
20 | // the weird visually broken 1x2 size pond, then return it
21 | if (points.length > 2) return points;
22 |
23 | // Else try again to make a nice pond shape
24 | return createPondShape(width, height);
25 | };
26 |
27 | export const spawnPond = ({
28 | width, height, x, y,
29 | }) => {
30 | let points = createPondShape(width, height);
31 | const avoidancePoints = [];
32 |
33 | // Convert the points into world-space SVG grid points
34 | points = points.map((p) => ({
35 | x: x + p.x + Math.floor(width / 2),
36 | y: y + p.y + Math.floor(height / 2),
37 | }));
38 |
39 | // The entire width and height, not just the cells taken up by the point,
40 | // are to be avoided when generating new points, to avoid corner-y overlaps
41 | for (let h = 0; h < height; h++) {
42 | for (let w = 0; w < width; w++) {
43 | avoidancePoints.push({
44 | x: x + w,
45 | y: y + h,
46 | });
47 | }
48 | }
49 |
50 | ponds.push({
51 | width, height, x, y, points, avoidancePoints,
52 | });
53 |
54 | const outline = getOutlinePoints(points);
55 |
56 | const pondSvg = createSvgElement('path');
57 | pondSvg.setAttribute('fill', '#69b');
58 | const d = outline.reduce((acc, curr, index) => {
59 | // const pondDot = createSvgElement('circle');
60 | // pondDot.style.transform = `translate(${x}px,${y}px)`;
61 | // pondDot.setAttribute('r', 1);
62 | // pondDot.setAttribute('fill', ['red', 'blue', 'green', 'yellow', 'black', 'white'][index]);
63 | // svgElement.append(pondDot);
64 |
65 | const next = outline.at((index + 1) % outline.length);
66 | // console.log(index % outline.length);
67 | const end = {
68 | x: curr.x + ((next.x - curr.x) / 2),
69 | y: curr.y + ((next.y - curr.y) / 2),
70 | };
71 |
72 | return `${acc} ${gridCellSize / 2 + curr.x * gridCellSize} ${gridCellSize / 2 + curr.y * gridCellSize} ${gridCellSize / 2 + end.x * gridCellSize} ${gridCellSize / 2 + end.y * gridCellSize}`;
73 | }, `M${gridCellSize / 2 + (outline[0].x + ((outline.at(-1).x - outline[0].x) / 2)) * gridCellSize} ${gridCellSize / 2 + (outline[0].y + ((outline.at(-1).y - outline[0].y) / 2)) * gridCellSize}Q`);
74 |
75 | pondSvg.setAttribute('d', `${d}Z`);
76 | pondSvg.setAttribute('stroke-width', 4);
77 | pondSvg.setAttribute('stroke-linejoin', 'round');
78 | pondSvg.setAttribute('stroke', '#6ab');
79 |
80 | const pondShadeSvg = createSvgElement('path');
81 | pondShadeSvg.setAttribute('fill', '#7bc');
82 | pondShadeSvg.setAttribute('d', `${d}Z`);
83 | pondShadeSvg.setAttribute('stroke', '#7bc');
84 | pondShadeSvg.style.filter = 'blur(2px)';
85 |
86 | const pondEdgeSvg = createSvgElement('path');
87 | pondEdgeSvg.setAttribute('d', `${d}Z`);
88 | pondEdgeSvg.setAttribute('stroke-width', 6);
89 | pondEdgeSvg.setAttribute('stroke', '#9b6');
90 |
91 | pondLayer.append(pondEdgeSvg, pondSvg, pondShadeSvg);
92 | };
93 |
--------------------------------------------------------------------------------
/src/find-route.js:
--------------------------------------------------------------------------------
1 | import { paths } from './path';
2 | import { gridWidth, gridHeight } from './svg';
3 |
4 | let gridData = [];
5 |
6 | export const updateGridData = () => {
7 | gridData = [];
8 |
9 | for (let x = 0; x < gridWidth; x++) {
10 | for (let y = 0; y < gridHeight; y++) {
11 | gridData.push({ x, y, neighbors: [] });
12 | }
13 | }
14 |
15 | paths.forEach((path) => {
16 | gridData
17 | .find((d) => d.x === path.points[0].x && d.y === path.points[0].y)
18 | .neighbors
19 | .push({ x: path.points[1].x, y: path.points[1].y });
20 |
21 | gridData
22 | .find((d) => d.x === path.points[1].x && d.y === path.points[1].y)
23 | .neighbors
24 | .push({ x: path.points[0].x, y: path.points[0].y });
25 | });
26 | };
27 |
28 | const breadthFirstSearch = (currentGridData, from, to) => {
29 | const queue = [{ node: from, path: [] }];
30 | const visited = [];
31 |
32 | while (queue.length) {
33 | const { node, path } = queue.shift();
34 |
35 | if (node === undefined) {
36 | // Not sure how nodes could be undefined but fine?
37 | return undefined;
38 | }
39 |
40 | // Are we at the end?
41 | if (to.find((t) => node.x === t.x && node.y === t.y)) {
42 | return path.concat(node);
43 | }
44 |
45 | const hasVisited = visited
46 | .some((visitedNode) => visitedNode.x === node.x && visitedNode.y === node.y);
47 |
48 | if (!hasVisited) {
49 | visited.push(node);
50 |
51 | const verticalHorizontalNeighbors = [];
52 | const diagonalNeighbors = [];
53 |
54 | node.neighbors.forEach((neighbor) => {
55 | if (Math.abs(neighbor.x - node.x) === 1 && neighbor.y === node.y) {
56 | verticalHorizontalNeighbors.push(neighbor);
57 | } else if (Math.abs(neighbor.y - node.y) === 1 && neighbor.x === node.x) {
58 | verticalHorizontalNeighbors.push(neighbor);
59 | } else {
60 | diagonalNeighbors.push(neighbor);
61 | }
62 | });
63 |
64 | verticalHorizontalNeighbors.forEach((neighbor) => {
65 | const hasVisitedNeighbor = visited.some(
66 | (visitedNode) => visitedNode.x === neighbor.x && visitedNode.y === neighbor.y,
67 | );
68 |
69 | if (!hasVisitedNeighbor) {
70 | queue.push({
71 | node: currentGridData.find((c) => c.x === neighbor.x && c.y === neighbor.y),
72 | path: path.concat({
73 | ...node,
74 | distance: 1,
75 | }),
76 | });
77 | }
78 | });
79 |
80 | diagonalNeighbors.forEach((neighbor) => {
81 | const hasVisitedNeighbor = visited.some(
82 | (visitedNode) => visitedNode.x === neighbor.x && visitedNode.y === neighbor.y,
83 | );
84 |
85 | if (!hasVisitedNeighbor) {
86 | queue.push({
87 | node: currentGridData.find((c) => c.x === neighbor.x && c.y === neighbor.y),
88 | path: path.concat({
89 | ...node,
90 | distance: 1.41, // Approx Math.sqrt(2)
91 | }),
92 | });
93 | }
94 | });
95 | }
96 | }
97 |
98 | return undefined; // Can't get there at all!
99 | };
100 |
101 | export const findRoute = ({ from, to }) => {
102 | // const gridData = gridDatagetGridData();
103 |
104 | // Convert from and to to actual grid nodes
105 | const fromNode = gridData.find((c) => c.x === from.x && c.y === from.y);
106 | const toNodes = gridData.filter((c) => to.find((f) => c.x === f.x && c.y === f.y));
107 |
108 | return breadthFirstSearch(
109 | gridData,
110 | fromNode,
111 | toNodes,
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/src/grid.js:
--------------------------------------------------------------------------------
1 | import {
2 | svgElement, boardOffsetX, boardOffsetY, boardSvgWidth, boardSvgHeight, gridCellSize,
3 | } from './svg';
4 | import { createSvgElement } from './svg-utils';
5 | import { colors } from './colors';
6 |
7 | export const scaledGridLineThickness = 1;
8 | export const gridLineThickness = scaledGridLineThickness / 2;
9 |
10 | export const gridRect = createSvgElement('rect');
11 | export const gridRectRed = createSvgElement('rect');
12 | export const gridPointerHandler = createSvgElement('rect');
13 |
14 | export const addGridBackgroundToSvg = () => {
15 | const gridRectBackground = createSvgElement('rect');
16 | gridRectBackground.setAttribute('fill', colors.grass);
17 | gridRectBackground.setAttribute('width', `${boardSvgWidth + gridLineThickness}px`);
18 | gridRectBackground.setAttribute('height', `${boardSvgHeight + gridLineThickness}px`);
19 | gridRectBackground.setAttribute('transform', `translate(${boardOffsetX * gridCellSize - gridLineThickness / 2} ${boardOffsetY * gridCellSize - gridLineThickness / 2})`);
20 |
21 | svgElement.append(gridRectBackground);
22 | };
23 |
24 | export const addGridToSvg = () => {
25 | // The entire games grid, including non-buildable area off the board
26 |
27 | const defs = createSvgElement('defs');
28 | svgElement.append(defs);
29 |
30 | const pattern = createSvgElement('pattern');
31 | pattern.setAttribute('id', 'grid'); // Required for defs, could maybe be minified
32 | pattern.setAttribute('width', gridCellSize);
33 | pattern.setAttribute('height', gridCellSize);
34 | pattern.setAttribute('patternUnits', 'userSpaceOnUse');
35 | defs.append(pattern);
36 | const gridPath = createSvgElement('path');
37 | gridPath.setAttribute('d', `M${gridCellSize} 0 0 0 0 ${gridCellSize}`);
38 | gridPath.setAttribute('fill', 'none');
39 | gridPath.setAttribute('stroke', colors.grid);
40 | gridPath.setAttribute('stroke-width', scaledGridLineThickness);
41 | pattern.append(gridPath);
42 | gridRect.setAttribute('width', `${boardSvgWidth + gridLineThickness}px`);
43 | gridRect.setAttribute('height', `${boardSvgHeight + gridLineThickness}px`);
44 | gridRect.setAttribute('transform', `translate(${boardOffsetX * gridCellSize - gridLineThickness / 2} ${boardOffsetY * gridCellSize - gridLineThickness / 2})`);
45 | gridRect.setAttribute('fill', 'url(#grid)');
46 | gridRect.style.opacity = 0;
47 | gridRect.style.willChange = 'opacity';
48 | gridRect.style.transition = 'opacity.3s';
49 |
50 | const patternRed = createSvgElement('pattern');
51 | patternRed.setAttribute('id', 'gridred'); // Required for defs, could maybe be minified
52 | patternRed.setAttribute('width', gridCellSize);
53 | patternRed.setAttribute('height', gridCellSize);
54 | patternRed.setAttribute('patternUnits', 'userSpaceOnUse');
55 | defs.append(patternRed);
56 | const gridPathRed = createSvgElement('path');
57 | gridPathRed.setAttribute('d', `M${gridCellSize} 0 0 0 0 ${gridCellSize}`);
58 | gridPathRed.setAttribute('fill', 'none');
59 | gridPathRed.setAttribute('stroke', colors.gridRed);
60 | gridPathRed.setAttribute('stroke-width', scaledGridLineThickness);
61 | patternRed.append(gridPathRed);
62 | gridRectRed.setAttribute('width', `${boardSvgWidth + gridLineThickness}px`);
63 | gridRectRed.setAttribute('height', `${boardSvgHeight + gridLineThickness}px`);
64 | gridRectRed.setAttribute('transform', `translate(${boardOffsetX * gridCellSize - gridLineThickness / 2} ${boardOffsetY * gridCellSize - gridLineThickness / 2})`);
65 | gridRectRed.setAttribute('fill', 'url(#gridred)');
66 | gridRectRed.style.opacity = 0;
67 | gridRectRed.style.willChange = 'opacity';
68 | gridRectRed.style.transition = 'opacity.3s';
69 |
70 | svgElement.append(gridRect, gridRectRed);
71 | };
72 |
73 | export const gridToSvgCoords = (object) => ({
74 | x: (boardOffsetX + object.x) * gridCellSize,
75 | y: (boardOffsetY + object.y) * gridCellSize,
76 | });
77 |
--------------------------------------------------------------------------------
/src/grid-toggle.js:
--------------------------------------------------------------------------------
1 | import { svgHazardLines, svgHazardLinesRed } from './svg';
2 | import { gridRect, gridRectRed } from './grid';
3 | import { gridPointerLayer } from './layers';
4 | import {
5 | gridToggleSvgPath, gridRedToggleSvgPath, gridToggleTooltip, gridRedToggleTooltip,
6 | } from './ui';
7 | import { initAudio, playSound } from './audio';
8 |
9 | let gridLocked = localStorage.getItem('Tiny Yurtsg') === 'true';
10 |
11 | export const gridRedState = {
12 | locked: false,
13 | on: false,
14 | };
15 |
16 | export const gridShow = () => {
17 | svgHazardLines.style.opacity = 0.9;
18 | gridRect.style.opacity = 1;
19 |
20 | if (!gridLocked) {
21 | // #
22 | gridToggleSvgPath.setAttribute('d', 'M6 5 6 11M10 5 10 11M5 6 8 6 11 6M5 10 11 10');
23 | gridToggleSvgPath.style.transform = 'rotate(180deg)';
24 | }
25 | };
26 |
27 | export const gridHide = () => {
28 | if (!gridLocked) {
29 | svgHazardLines.style.opacity = 0;
30 | gridRect.style.opacity = 0;
31 |
32 | // A
33 | gridToggleSvgPath.setAttribute('d', 'M8 4.5 5 11M8 4.5 11 11M5 11 8 4.5 11 11M6 9.5 10 9.5');
34 | gridToggleSvgPath.style.transform = 'rotate(0)';
35 | }
36 | };
37 |
38 | if (gridLocked) {
39 | gridToggleTooltip.innerHTML = 'Grid: On';
40 | gridShow();
41 | gridToggleSvgPath.setAttribute('d', 'M6 5 6 11M10 5 10 11M5 6 8 6 11 6M5 10 11 10');
42 | gridToggleSvgPath.style.transform = 'rotate(180deg)';
43 | } else {
44 | gridToggleTooltip.innerHTML = 'Grid: Auto';
45 | gridHide();
46 | }
47 |
48 | export const gridLockToggle = () => {
49 | initAudio();
50 |
51 | if (gridLocked) {
52 | gridLocked = false;
53 | gridHide();
54 | localStorage.setItem('Tiny Yurtsg', false);
55 | gridToggleTooltip.innerHTML = 'Grid: Auto';
56 | } else {
57 | gridShow();
58 | localStorage.setItem('Tiny Yurtsg', true);
59 | gridLocked = true;
60 | gridToggleTooltip.innerHTML = 'Grid: On';
61 | }
62 |
63 | playSound(25, 1, 1, 1, 0.3, 1000, 1000);
64 | };
65 |
66 | export const gridRedShow = () => {
67 | gridPointerLayer.style.cursor = 'crosshair';
68 | gridRectRed.style.opacity = 0.9;
69 | svgHazardLinesRed.style.opacity = 0.9;
70 |
71 | if (!gridRedState.locked) {
72 | // ☒ (trash / bulldoze mode)
73 | gridRedToggleSvgPath.setAttribute(
74 | 'd',
75 | 'M4.5 4.5Q4.5 4.5 11.5 4.5 11.5 4.5 11.5 4.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 4.5 11.5 4.5 11.5ZM9 7 7 9M7 7Q9 9 9 9',
76 | );
77 | gridRedToggleSvgPath.style.transform = 'rotate(180deg)';
78 | }
79 | };
80 |
81 | export const gridRedHide = () => {
82 | if (!gridRedState.locked) {
83 | gridRectRed.style.opacity = 0;
84 | svgHazardLinesRed.style.opacity = 0;
85 |
86 | // Right Click
87 | gridRedToggleSvgPath.setAttribute('d', 'M5 7Q5 4 8 4 11 4 11 7 11 8 11 9 11 12 8 12 5 12 5 9ZM8 4 8 8 M8 8 Q11 8 11 7.5');
88 | gridRedToggleSvgPath.style.transform = 'rotate(0)';
89 | }
90 | };
91 |
92 | if (gridRedState.locked) {
93 | gridRedToggleTooltip.innerHTML = 'Delete: On';
94 | // ☒ (trash / bulldoze mode)
95 | gridRedToggleSvgPath.setAttribute(
96 | 'd',
97 | 'M4.5 4.5 Q4.5 4.5 11.5 4.5 11.5 4.5 11.5 4.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 4.5 11.5 4.5 11.5ZM9 7 7 9M7 7Q9 9 9 9',
98 | );
99 | gridRedToggleSvgPath.style.transform = 'rotate(180deg)';
100 | } else {
101 | gridRedToggleTooltip.innerHTML = 'Delete: RMB';
102 | // A
103 | gridRedToggleSvgPath.setAttribute('d', 'M5 7Q5 4 8 4 11 4 11 7 11 8 11 9 11 12 8 12 5 12 5 9ZM8 4 8 8 M8 8 Q11 8 11 7.5');
104 | gridRedToggleSvgPath.style.transform = 'rotate(0)';
105 | }
106 |
107 | export const gridRedLockToggle = () => {
108 | initAudio();
109 |
110 | if (gridRedState.locked) {
111 | gridRedState.locked = false;
112 | gridRedHide();
113 | gridRedToggleTooltip.innerHTML = 'Delete: RMB';
114 | } else {
115 | gridRedShow();
116 | gridRedState.locked = true;
117 | gridRedToggleTooltip.innerHTML = 'Delete: On';
118 | }
119 |
120 | playSound(27, 1, 1, 1, 0.3, 1000, 1000);
121 | };
122 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tiny Yurts
2 |
3 |
4 |
5 | ### [Play online](https://burnt.io/tiny-yurts/)
6 |
7 | > A web game inspired by [Dinosaur Polo Club's](https://dinopoloclub.com/) [Mini Motorways](https://dinopoloclub.com/games/mini-motorways/), created for [Js13kGames](https://js13kgames.com/) 2023
8 | > \- the total size of the [zipped](dist/game.zip) [index.html](dist/index.html) is under 13,312B!
9 |
10 | ### How to play
11 |
12 | - Touch or left click and drag to build paths between your yurts and farms to keep the animals happy!
13 | - You get points for your total number of settlers (2x your number of yurts), plus a point for each animal.
14 | - __Fullscreen__ is highly recommended for mobile.
15 |
16 | ### Tech used
17 | - All the graphics are SVG-based, with CSS transitions and transforms. There is no canvas, and there are no asset files. It's HTML-CSS-SVG-in-JS all the way down.
18 | - JavaScript packer [Roadroller](https://lifthrasiir.github.io/roadroller/) by [Kang Seonghoon](https://mearie.org/).
19 | - [Kontra.js](https://straker.github.io/kontra/) game engine by [Steven Lambert](https://stevenklambert.com/).
20 | - [Karplus-Strong](https://en.wikipedia.org/wiki/Karplus%E2%80%93Strong_string_synthesis) via the [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API), from [xem's](https://xem.github.io/) [MiniSynth](https://github.com/xem/js1k19/blob/gh-pages/miniSynth/index.html), based on [Keith Horwood's](https://keithwhor.com/) [audiosynth](https://github.com/keithwhor/audiosynth).
21 | - [JSZip](https://stuk.github.io/jszip/) _and_ [advzip-bin](https://github.com/elliot-nelson/advzip-bin) for zip compression.
22 | - [Vite](https://vitejs.dev/) and [Terser](https://terser.org/) with a messy, unstable, project-specific [custom plugin](plugins/vite-js13k.js) for maximum minification.
23 |
24 | ### Tips & Tricks
25 | 28 |
${firstLine + secondLine}`); 72 | } 73 | 74 | export function replaceCss(html, scriptFilename, scriptCode) { 75 | const reCss = new RegExp(`]*? href="[./]*${scriptFilename}"[^>]*?>`); 76 | 77 | return html.replace(reCss, ``); 78 | } 79 | 80 | function replaceHtml(html) { 81 | const _html = htmlMinifier.minify(html, { 82 | collapseWhitespace: true, 83 | removeAttributeQuotes: true, 84 | }); 85 | 86 | return _html 87 | .replace('', '') 88 | .replace('', '') 89 | .replace('"width=device-width,initial-scale=1"', 'width=device-width,initial-scale=1') 90 | .replace(/ lang=[^>]*/, ''); 91 | } 92 | 93 | const fileRegex = /\.js$/ 94 | 95 | function customReplacement(src) { 96 | const replaced = src 97 | // Minify CSS template literals. Use `` to wrap CSS in JS even when no 98 | // variables are present, to apply the following. Some strings, like 99 | // 'Highscore:' could be broken by this and must be fixed during the build 100 | .replace(/`[^`]+`/g, tag => tag 101 | .replace(/`\s+/, '`') // Remove newlines & spaces at start or string 102 | .replace(/\n\s+/g, '') // Remove newlines & spaces within values 103 | .replace(/:\s+/g, ':') // Remove spaces in between property & values 104 | .replace(/\,\s+/g, ',') // Remove space after commas 105 | .replace(/\s{/g, '{') // Remove space in between identifier & opening squigly 106 | .replace(/([a-z])\s+\./g, '$1.') // Remove space between transition timing & .s 107 | .replace(/(%) ([\d$])/g, '$1$2') // Remove space between '100% 50%' in hwb() 108 | .replace(/\s\/\s/g, '/') // Remove spaces around `/` in hsl 109 | .replace(/;\s+/g, ';') // Remove newlines & spaces after semicolons 110 | .replace(/\)\s/g, ')') // Remove spaces after closing brackets 111 | .replace(/;}/g, '}') // Remove final semicolons in blocks 112 | .replace(/;`/, '`') // Remove final semicolons in cssText 113 | ) 114 | .replace(/M0 0l/g, 'M0 0 ') // Don't need line char, can just use space instead 115 | .replace(/M0 0L/g, 'M0 0 ') // This has been swapped out in source, mostly, anyway. 116 | .replace(/upgrade/g, '_upgrade') 117 | // .replace(/type/g, '_type') // Breaks Web Audio API 118 | .replace(/parent/g, '_parent') 119 | .replace(/points/g, '_points') 120 | .replace(/fixed/g, '_fixed') 121 | .replace(/acceleration/g, '_acceleration') 122 | // .replace(/destination/g, '_destination') // Breaks paths 123 | .replace(/anchor/g, '_anchor') 124 | .replace(/locked/g, '_locked') 125 | // .replace(/normalize/g, '_normalize') // Breaks people movement 126 | // Target breaks pause button event.target check to not double-press on spacebar 127 | // .replace(/target/g, '_target') 128 | .replace(/maxDistance/g, '_maxDistance') 129 | .replace(/baseLayer/g, '_baseLayer') 130 | // Replace const with let declartion 131 | .replaceAll('const ', 'let ') 132 | // Replace all strict equality comparison with abstract equality comparison 133 | .replaceAll('===', '==') 134 | .replaceAll('!==', '!=') 135 | // Fix accidentally "minified" highscore text 136 | .replaceAll('Highscore:', 'Highscore: ') 137 | // .replace(/update/g, '_update') 138 | 139 | return replaced; 140 | } 141 | 142 | export function viteJs13kPre() { 143 | return { 144 | enforce: 'pre', 145 | transform(src, id) { 146 | if (fileRegex.test(id)) { 147 | return { 148 | code: customReplacement(src), 149 | map: null 150 | } 151 | } 152 | } 153 | } 154 | } 155 | 156 | export function viteJs13k() { 157 | return { 158 | enforce: "post", 159 | generateBundle: async (_, bundle) => { 160 | const jsExtensionTest = /\.[mc]?js$/; 161 | const htmlFiles = Object.keys(bundle).filter((i) => i.endsWith(".html")); 162 | const cssAssets = Object.keys(bundle).filter((i) => i.endsWith(".css")); 163 | const jsAssets = Object.keys(bundle).filter((i) => jsExtensionTest.test(i)); 164 | const bundlesToDelete = []; 165 | for (const name of htmlFiles) { 166 | const htmlChunk = bundle[name]; 167 | let replacedHtml = htmlChunk.source; 168 | 169 | for (const jsName of jsAssets) { 170 | const jsChunk = bundle[jsName]; 171 | if (jsChunk.code != null) { 172 | bundlesToDelete.push(jsName); 173 | replacedHtml = await replaceScript(replacedHtml, jsChunk.fileName, jsChunk.code); 174 | } 175 | } 176 | 177 | for (const cssName of cssAssets) { 178 | const cssChunk = bundle[cssName]; 179 | bundlesToDelete.push(cssName); 180 | replacedHtml = replaceCss(replacedHtml, cssChunk.fileName, cssChunk.source); 181 | } 182 | 183 | replacedHtml = replaceHtml(replacedHtml); 184 | htmlChunk.source = replacedHtml; 185 | await zip(replacedHtml); 186 | } 187 | for (const name of bundlesToDelete) { 188 | delete bundle[name]; 189 | } 190 | }, 191 | closeBundle: () => { 192 | console.log(`\nZip size: ${fs.statSync('dist/game.zip').size}B`); 193 | 194 | execFile(advzip, [ 195 | '--recompress', 196 | '--shrink-insane', 197 | '--iter=8000', 198 | 'dist/game.zip' 199 | ], (err) => { 200 | console.log(`\nZip size: ${fs.statSync('dist/game.zip').size}B (advzip)`); 201 | }); 202 | }, 203 | }; 204 | } 205 | -------------------------------------------------------------------------------- /src/audio.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable array-bracket-spacing */ 2 | import { colors } from './colors'; 3 | 4 | // This must only be called on user interaction. So probably on pressing a 5 | // main menu button? But we don't want to re-do it er ever as well hrm 6 | let audioContext; 7 | 8 | // Sample rate in Hz. Could be fetched with audioContext.sampleRate, but writing 9 | // out just the number will minify slightly better 10 | const sampleRate = 44100; 11 | 12 | export const soundSetings = { 13 | on: localStorage.getItem('Tiny Yurtss') !== 'false', 14 | }; 15 | 16 | export const initAudio = () => { 17 | if (!audioContext) { 18 | audioContext = new AudioContext(); 19 | } 20 | 21 | // Do we need to return it?... 22 | return audioContext; 23 | }; 24 | 25 | export const playSound = ( 26 | frequencyIndex, 27 | noteLength = 2, 28 | playbackRate = 1, 29 | // Makes the note not go on for as long, like it's pinging a tighter string 30 | pingyness = 1, 31 | volume = 1, 32 | // Most sound below this frequency (Hz) goes through 33 | lowpassFrequency = 10000, 34 | // Most sound above this frequency (Hz) goes through 35 | highpassFrequency = 100, 36 | noise = () => (2 * Math.random() - 1), 37 | ) => { 38 | if (!soundSetings.on) return; 39 | 40 | // Magic maths to get an index to line up with musical notes 41 | const frequency = 130.81 * 1.0595 ** frequencyIndex; 42 | const bufferData = []; 43 | const v = []; 44 | let p = 0; 45 | const period = sampleRate / frequency; 46 | let reset; 47 | 48 | const w = () => { 49 | reset = false; 50 | return v.length <= 1 + Math.floor(period) 51 | ? (v.push(noise()), v.at(-1)) 52 | : ( 53 | v[p] = ( 54 | v[p >= v.length - 1 ? 0 : p + 1] * 0.5 + v[p] * (0.5 - (pingyness / 1000)) 55 | // v[p >= v.length - 1 ? 0 : p + 1] 56 | ), 57 | p >= Math.floor(period) && ( 58 | reset = true, v[p + 1] = (v[0] * 0.5 + v[p + 1] * 0.5) 59 | ), 60 | p = reset ? 0 : p + 1, 61 | v[p] 62 | ); 63 | }; 64 | 65 | for ( 66 | let i = 0; 67 | i < sampleRate * noteLength; 68 | i++ 69 | ) { 70 | bufferData[i] = i < 88 71 | ? (i / 88) * w() 72 | : (1 - (i - 88) / (sampleRate * noteLength)) * w(); 73 | } 74 | 75 | const buffer = audioContext.createBuffer(1, sampleRate * noteLength, sampleRate); 76 | buffer.getChannelData(0).set(bufferData); 77 | 78 | const source = audioContext.createBufferSource(); 79 | source.buffer = buffer; 80 | source.playbackRate.value = playbackRate; 81 | 82 | const lowpassNode = audioContext.createBiquadFilter(); 83 | lowpassNode.type = 'lowpass'; 84 | lowpassNode.frequency.value = lowpassFrequency; 85 | 86 | // Two low pass filters for more aggressive filtering, 87 | // without using many more bytes as they're identical 88 | const lowpassNode2 = audioContext.createBiquadFilter(); 89 | lowpassNode2.type = 'lowpass'; 90 | lowpassNode2.frequency.value = lowpassFrequency; 91 | 92 | const highpassNode = audioContext.createBiquadFilter(); 93 | highpassNode.type = 'highpass'; 94 | highpassNode.frequency.value = highpassFrequency; 95 | 96 | const volumeNode = audioContext.createGain(); 97 | volumeNode.gain.value = volume; 98 | 99 | source.connect(lowpassNode); 100 | lowpassNode.connect(lowpassNode2); 101 | lowpassNode2.connect(highpassNode); 102 | highpassNode.connect(volumeNode); 103 | volumeNode.connect(audioContext.destination); 104 | source.start(); 105 | }; 106 | 107 | // Ox & Goat tunes are based off: 108 | // Ravel's Ma mère l'oye (1910) III. Laideronnette, impératrice des pagodes 109 | // https://en.wikipedia.org/wiki/File:Ravel_Ma_Mere_l%27Oye_Laideronnette_Imperatricedes_Pagodes_m.9-13.png 110 | 111 | // [ frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass ] 112 | const warnNotes = { 113 | [colors.ox]: { 114 | currentIndex: 0, 115 | notes: [ 116 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 117 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 118 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 119 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 120 | 121 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 122 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 123 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 124 | 125 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 126 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 127 | 128 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 129 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 130 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 131 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 132 | 133 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 134 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 135 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 136 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 137 | 138 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 139 | [18, 0.5, 0.5, 30, 0.2, 1800, 200], // F# 140 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 141 | 142 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 143 | [15, 0.5, 0.5, 30, 0.2, 1800, 200], // D# 144 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 145 | [13, 0.5, 0.5, 30, 0.2, 1800, 200], // C# 146 | 147 | [ 8, 0.5, 0.5, 30, 0.2, 1800, 200], // G# (first one) 148 | [10, 0.5, 0.5, 30, 0.2, 1800, 200], // A# 149 | [ 5, 0.5, 0.5, 30, 0.2, 1800, 200], // E# (F) 150 | [ 8, 0.5, 0.5, 30, 0.2, 1800, 200], // G# 151 | ], 152 | }, 153 | [colors.goat]: { 154 | currentIndex: 0, 155 | notes: [ 156 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 157 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 0.995 is annoying but repeated isn't too bad 158 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 159 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 160 | 161 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 162 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 163 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 164 | 165 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 166 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 167 | 168 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 169 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 170 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 171 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 172 | 173 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 174 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 175 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 176 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 177 | 178 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 179 | [30, 1, 1, 1, 0.2, 3000, 1000], // F# 180 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 181 | 182 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 183 | [27, 1, 0.995, 1, 0.2, 3000, 1000], // D# 184 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 185 | [25, 1, 1, 1, 0.2, 3000, 1000], // C# 186 | 187 | [20, 1, 1, 1, 0.2, 3000, 1000], // G# (first one) 188 | [22, 1, 1, 1, 0.2, 3000, 1000], // A# 189 | [17, 1, 1, 1, 0.2, 3000, 1000], // E# (i.e. F. Music is weird) 190 | [20, 1, 1, 1, 0.2, 3000, 1000], // G# 191 | ], 192 | }, 193 | [colors.fish]: { 194 | currentIndex: 0, 195 | notes: [ 196 | [70, 0.1, 0.05, 900, 1, 1000, 200], 197 | [73, 0.1, 0.05, 900, 1, 1000, 200], 198 | [68, 0.1, 0.05, 900, 1, 1000, 200], 199 | [70, 0.1, 0.05, 900, 1, 1000, 200], 200 | [70, 0.1, 0.05, 900, 1, 1000, 200], 201 | [73, 0.1, 0.05, 900, 1, 1000, 200], 202 | [68, 0.1, 0.05, 900, 1, 1000, 200], 203 | ], 204 | }, 205 | }; 206 | 207 | export const playPathPlacementNote = () => { 208 | if (audioContext) { 209 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 210 | playSound(1, 0.5, 1, 0, 1, 1000, 300, () => 2); 211 | } 212 | }; 213 | 214 | export const playPathDeleteNote = () => { 215 | if (audioContext) { 216 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 217 | playSound(1, 0.5, 1, 0, 6, 800, 1500, () => 2); 218 | } 219 | }; 220 | 221 | export const playTreeDeleteNote = () => { 222 | if (audioContext) { 223 | playSound(10, 0.1, 1, 1000, 0.2, 1500, 500, () => 2); 224 | } 225 | }; 226 | 227 | export const playYurtSpawnNote = () => { 228 | if (audioContext) { 229 | playSound(39, 0.1, 0.25, 10, 0.2, 1000, 100); // E# (i.e. F. Music is weird) 230 | } 231 | }; 232 | 233 | export const playOutOfPathsNote = () => { 234 | if (audioContext) { 235 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 236 | // playSound(18, 0.5, 0.25, 30, 0.2, 1800, 200); 237 | setTimeout(() => playSound(8, 0.5, 0.5, 40, 0.1, 1000, 100), 100); 238 | setTimeout(() => playSound(5, 0.5, 0.5, 20, 0.1, 1000, 100), 250); 239 | } 240 | }; 241 | 242 | export const playWarnNote = (animalType) => { 243 | if (audioContext) { 244 | const notes = warnNotes[animalType]; 245 | const noteInfo = notes.notes[notes.currentIndex]; 246 | notes.currentIndex = (notes.currentIndex + 1) % notes.notes.length; 247 | playSound(...noteInfo); 248 | // const { currentIndex, notes } = warnNotes[animalType]; 249 | // playSound(notes[currentIndex++]); 250 | } 251 | }; 252 | -------------------------------------------------------------------------------- /src/gameover.js: -------------------------------------------------------------------------------- 1 | import { emojiGoat } from './goat-emoji'; 2 | import { emojiOx } from './ox-emoji'; 3 | import { emojiFish } from './fish-emoji'; 4 | import { oxen } from './ox'; 5 | import { goats } from './goat'; 6 | import { fishes } from './fish'; 7 | import { animals } from './animal'; 8 | import { yurts } from './yurt'; 9 | import { colors } from './colors'; 10 | import { menuBackground } from './menu-background'; 11 | import { 12 | scoreCounters, uiContainer, gridToggleButton, soundToggleButton, gridRedToggleButton, 13 | } from './ui'; 14 | import { createElement } from './create-element'; 15 | 16 | const gameoverWrapper = createElement(); 17 | const gameoverHeader = createElement(); 18 | const gameoverText1 = createElement(); 19 | const gameoverText2 = createElement(); 20 | const gameoverText3 = createElement(); 21 | const gameoverButtons = createElement(); 22 | const restartButtonWrapper = createElement(); 23 | const restartButton = createElement('button'); 24 | const menuButtonWrapper = createElement(); 25 | const menuButton = createElement('button'); 26 | const oxEmojiWrapper = createElement(); 27 | const oxEmoji = emojiOx(); 28 | const goatEmojiWrapper = createElement(); 29 | const goatEmoji = emojiGoat(); 30 | const fishEmojiWrapper = createElement(); 31 | const fishEmoji = emojiFish(); 32 | const scoreWrapper = createElement(); 33 | export const toggleGameoverlayButton = createElement('button'); 34 | 35 | export const initGameover = (startNewGame, gameoverToMenu, toggleGameoverlay) => { 36 | gameoverWrapper.style.cssText = ` 37 | position: absolute; 38 | inset: 0; 39 | padding: 10vmin; 40 | display: flex; 41 | flex-direction: column; 42 | `; 43 | gameoverWrapper.style.pointerEvents = 'none'; 44 | gameoverWrapper.style.opacity = 0; 45 | 46 | gameoverHeader.style.cssText = `font-size: 72px; opacity: 0`; 47 | gameoverHeader.innerText = 'Game Over'; 48 | 49 | gameoverText1.style.cssText = `margin-top: 48px; font-size: 24px; opacity:0`; 50 | gameoverText1.innerText = 'Too few people could tend to this farm in time.'; 51 | 52 | gameoverText2.style.cssText = `margin-top: 16px; font-size: 24px; opacity: 0`; 53 | 54 | // 24px margin-top counteracts the underline in gameoverText2 55 | gameoverText3.style.cssText = ` 56 | display: flex; 57 | flex-wrap: wrap; 58 | gap: 4px; 59 | margin-top: 24px; 60 | font-size: 24px; 61 | `; 62 | gameoverText3.style.opacity = 0; 63 | if (document.body.scrollHeight < 500) { 64 | gameoverText3.style.position = 'absolute'; 65 | gameoverText3.style.bottom = '10vmin'; 66 | gameoverText3.style.right = '10vmin'; 67 | } else { 68 | gameoverText3.style.position = ''; 69 | gameoverText3.style.bottom = ''; 70 | gameoverText3.style.right = ''; 71 | } 72 | addEventListener('resize', () => { 73 | if (document.body.scrollHeight < 500) { 74 | gameoverText3.style.position = 'absolute'; 75 | gameoverText3.style.bottom = '10vmin'; 76 | gameoverText3.style.right = '10vmin'; 77 | } else { 78 | gameoverText3.style.position = ''; 79 | gameoverText3.style.bottom = ''; 80 | gameoverText3.style.right = ''; 81 | } 82 | }); 83 | 84 | oxEmojiWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 85 | goatEmojiWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 86 | fishEmojiWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 87 | scoreWrapper.style.cssText = `display:inline-flex;padding:6px 12px;line-height:24px;color:#fff;border-radius:64px;background:${colors.ui}`; 88 | oxEmoji.style.width = '24px'; 89 | oxEmoji.style.height = '24px'; 90 | goatEmoji.style.width = '24px'; 91 | goatEmoji.style.height = '24px'; 92 | fishEmoji.style.width = '24px'; 93 | fishEmoji.style.height = '24px'; 94 | 95 | menuButtonWrapper.style.opacity = 0; 96 | restartButtonWrapper.style.opacity = 0; 97 | menuButtonWrapper.append(menuButton); 98 | restartButtonWrapper.append(restartButton); 99 | restartButton.innerText = 'Restart'; 100 | menuButton.innerText = 'Menu'; 101 | 102 | restartButton.addEventListener('click', startNewGame); 103 | 104 | menuButton.addEventListener('click', gameoverToMenu); 105 | 106 | gameoverButtons.append(restartButtonWrapper, menuButtonWrapper); 107 | gameoverButtons.style.cssText = `gap: 16px; margin-top: 48px;`; 108 | if (document.body.scrollHeight < 500) { 109 | gameoverButtons.style.display = 'flex'; 110 | gameoverButtons.style.position = 'absolute'; 111 | gameoverButtons.style.bottom = '10vmin'; 112 | gameoverButtons.style.left = '10vmin'; 113 | } else { 114 | gameoverButtons.style.display = 'grid'; 115 | gameoverButtons.style.position = ''; 116 | gameoverButtons.style.bottom = ''; 117 | gameoverButtons.style.left = ''; 118 | } 119 | addEventListener('resize', () => { 120 | if (document.body.scrollHeight < 500) { 121 | gameoverButtons.style.display = 'flex'; 122 | gameoverButtons.style.position = 'absolute'; 123 | gameoverButtons.style.bottom = '10vmin'; 124 | gameoverButtons.style.left = '10vmin'; 125 | } else { 126 | gameoverButtons.style.display = 'grid'; 127 | gameoverButtons.style.position = ''; 128 | gameoverButtons.style.bottom = ''; 129 | gameoverButtons.style.left = ''; 130 | } 131 | }); 132 | 133 | toggleGameoverlayButton.style.cssText = `position: absolute; top: 10vmin; right: 10vmin`; 134 | toggleGameoverlayButton.style.pointerEvents = 'none'; 135 | toggleGameoverlayButton.style.opacity = 0; 136 | toggleGameoverlayButton.innerText = 'Overlay On/Off'; 137 | toggleGameoverlayButton.addEventListener('click', toggleGameoverlay); 138 | 139 | gameoverWrapper.append( 140 | gameoverHeader, 141 | gameoverText1, 142 | gameoverText2, 143 | gameoverText3, 144 | gameoverButtons, 145 | ); 146 | 147 | document.body.append(gameoverWrapper, toggleGameoverlayButton); 148 | }; 149 | 150 | export const showGameover = () => { 151 | const score = yurts.length * 2 + animals.length; 152 | uiContainer.style.zIndex = ''; 153 | 154 | if (score > localStorage.getItem('Tiny Yurts')) { 155 | localStorage.setItem('Tiny Yurts', score); 156 | } 157 | 158 | menuBackground.style.clipPath = `polygon(0 0, 100% 0, 100% 100%, 0 100%)`; 159 | menuBackground.style.transition = `opacity 2s 1s`; 160 | gameoverHeader.style.transition = `opacity .5s 2s`; 161 | gameoverText1.style.transition = `opacity .5s 2s`; 162 | gameoverText2.style.transition = `opacity .5s 2s`; 163 | gameoverText3.style.transition = `opacity .5s 2s`; 164 | restartButtonWrapper.style.transition = `opacity .5s 2.5s`; 165 | menuButtonWrapper.style.transition = `opacity .5s 3s`; 166 | toggleGameoverlayButton.style.transition = `all .2s, opacity .5s 3.5s`; 167 | 168 | oxEmojiWrapper.innerHTML = ''; 169 | oxEmojiWrapper.append(oxEmoji, `×${oxen.length}`); 170 | goatEmojiWrapper.innerHTML = ''; 171 | goatEmojiWrapper.append(goatEmoji, `×${goats.length}`); 172 | fishEmojiWrapper.innerHTML = ''; 173 | fishEmojiWrapper.append(fishEmoji, `×${fishes.length}`); 174 | scoreWrapper.innerHTML = `Score:${score}`; 175 | 176 | const peopleCount = createElement('u'); 177 | peopleCount.innerText = `${yurts.length * 2} settlers`; 178 | 179 | const animalsCount = createElement('u'); 180 | animalsCount.innerText = `${animals.length} animals`; 181 | 182 | gameoverText2.innerHTML = ''; 183 | gameoverText2.append(peopleCount, ' and ', animalsCount, ' lived in your camp.'); 184 | 185 | gameoverText3.innerHTML = ''; 186 | gameoverText3.append( 187 | oxEmojiWrapper, 188 | ' ', 189 | goatEmojiWrapper, 190 | ' ', 191 | fishEmojiWrapper, 192 | ' ', 193 | scoreWrapper, 194 | ); 195 | 196 | soundToggleButton.style.transition = `all .2s`; 197 | gridRedToggleButton.style.transition = `all .2s`; 198 | gridToggleButton.style.transition = `all .2s`; 199 | soundToggleButton.style.opacity = 0; 200 | gridRedToggleButton.style.opacity = 0; 201 | gridToggleButton.style.opacity = 0; 202 | scoreCounters.style.opacity = 0; 203 | 204 | setTimeout(() => { 205 | toggleGameoverlayButton.style.pointerEvents = ''; // Is separate from the gameoverWrapper 206 | gameoverWrapper.style.pointerEvents = ''; 207 | gameoverWrapper.style.opacity = 1; 208 | menuBackground.style.opacity = 1; 209 | gameoverHeader.style.opacity = 1; 210 | gameoverText1.style.opacity = 1; 211 | gameoverText2.style.opacity = 1; 212 | gameoverText3.style.opacity = 1; 213 | restartButtonWrapper.style.opacity = 1; 214 | menuButtonWrapper.style.opacity = 1; 215 | toggleGameoverlayButton.style.opacity = 1; 216 | }); 217 | }; 218 | 219 | export const hideGameover = () => { 220 | gameoverWrapper.style.transition = `opacity 1s 2s`; 221 | menuBackground.style.transition = `opacity 1s 1s`; 222 | gameoverHeader.style.transition = `opacity .3s .6s`; 223 | gameoverText1.style.transition = `opacity .3s .5s`; 224 | gameoverText2.style.transition = `opacity .3s .4s`; 225 | gameoverText3.style.transition = `opacity .3s .3s`; 226 | restartButtonWrapper.style.transition = `opacity .3s .2s`; 227 | menuButtonWrapper.style.transition = `opacity .3s .1s`; 228 | 229 | gameoverWrapper.style.pointerEvents = 'none'; 230 | gameoverWrapper.style.opacity = 0; 231 | menuBackground.style.opacity = 0; 232 | gameoverHeader.style.opacity = 0; 233 | gameoverText1.style.opacity = 0; 234 | gameoverText2.style.opacity = 0; 235 | gameoverText3.style.opacity = 0; 236 | restartButtonWrapper.style.opacity = 0; 237 | menuButtonWrapper.style.opacity = 0; 238 | }; 239 | -------------------------------------------------------------------------------- /src/person.js: -------------------------------------------------------------------------------- 1 | import { Vector } from 'kontra'; 2 | import { GameObjectClass } from './modified-kontra/game-object'; 3 | import { createSvgElement } from './svg-utils'; 4 | import { gridCellSize } from './svg'; 5 | import { colors } from './colors'; 6 | import { personLayer, yurtAndPersonShadowLayer } from './layers'; 7 | import { findRoute } from './find-route'; 8 | import { rotateVector, combineVectors } from './vector'; 9 | import { shuffle } from './shuffle'; 10 | 11 | export const people = []; 12 | 13 | export class Person extends GameObjectClass { 14 | constructor(properties) { 15 | super({ 16 | ...properties, 17 | }); 18 | 19 | const xVariance = Math.random() * 2 - 1; 20 | const yVariance = Math.random() * 2 - 1; 21 | 22 | this.type = this.parent.type; 23 | this.atHome = true; // Is this person sitting in their yurt? 24 | this.atFarm = 0; // Is the person at a farm? How long have they been there? 25 | this.destination = null; 26 | // Parent x/y is in grid coords instead of SVG coords, so need to convert 27 | this.x = gridCellSize / 2 + this.parent.x * gridCellSize + xVariance; 28 | this.y = gridCellSize / 2 + this.parent.y * gridCellSize + yVariance; 29 | 30 | people.push(this); 31 | } 32 | 33 | addToSvg() { 34 | const { x } = this; 35 | const { y } = this; 36 | 37 | const person = createSvgElement('path'); 38 | person.setAttribute('d', 'M0 0 0 0'); 39 | person.setAttribute('transform', `translate(${x},${y})`); 40 | person.setAttribute('stroke', this.type); 41 | personLayer.append(person); 42 | this.svgElement = person; 43 | 44 | const shadow = createSvgElement('path'); 45 | shadow.setAttribute('stroke-width', 1.2); 46 | shadow.setAttribute('d', 'M0 0 .3 .3'); 47 | shadow.setAttribute('transform', `translate(${x},${y})`); 48 | yurtAndPersonShadowLayer.append(shadow); 49 | this.shadowElement = shadow; 50 | } 51 | 52 | render() { 53 | if (!this.svgElement) return; 54 | 55 | const { x } = this; 56 | const { y } = this; 57 | 58 | this.svgElement.setAttribute('transform', `translate(${x},${y})`); 59 | this.shadowElement.setAttribute('transform', `translate(${x},${y})`); 60 | } 61 | 62 | update() { 63 | this.advance(); 64 | 65 | if (this.atHome || this.atFarm) { 66 | this.dx *= 0.9; 67 | this.dy *= 0.9; 68 | // TODO: Do this if at destination instead? 69 | // TODO: Set velocity to 0 when it gets little 70 | } 71 | 72 | if (this.atFarm) { 73 | // Go back home... soon! 74 | this.atFarm++; 75 | 76 | if (this.atFarm === 2 && this.farmToVisit.type === colors.fish) { 77 | // TODO: Give the "bob up to surface" function to the farm or the fish 78 | // eslint-disable-next-line max-len, no-param-reassign 79 | shuffle(this.farmToVisit.children).forEach((fish, i) => setTimeout(() => fish.svgBody.style.fill = colors.fish, i * 250)); 80 | } 81 | 82 | // After this many updates, go home 83 | // TODO: Make sensible number, show some sort of animation 84 | // originatlRoute.length counts every cell including yurt & farm 85 | if ( 86 | (this.atFarm > 80 && this.originalRoute.length > 3) 87 | || (this.atFarm > 120 && this.originalRoute.length > 2) 88 | || this.atFarm > 160 89 | ) { 90 | if (this.farmToVisit.type === colors.fish) { 91 | // TODO: Give the "bob up to surface" function to the farm or the fish 92 | // eslint-disable-next-line max-len, no-param-reassign 93 | shuffle(this.farmToVisit.children).forEach((fish, i) => setTimeout(() => fish.svgBody.style.fill = colors.shade2, 1000 + i * 1000)); 94 | } 95 | 96 | // Go back home. If no route is found, errrr dunno? 97 | const route = findRoute({ 98 | from: { 99 | x: this.destination.x, // from before 100 | y: this.destination.y, 101 | }, 102 | to: [{ 103 | x: this.parent.x, 104 | y: this.parent.y, 105 | }], 106 | }); 107 | 108 | if (route?.length) { 109 | this.goingHome = true; 110 | this.atFarm = 0; 111 | this.hasDestination = true; 112 | this.destination = route.at(-1); 113 | this.route = route; 114 | this.originalRoute = [...route]; 115 | } else { 116 | // Can't find way home :( 117 | // Reset atFarm so that the way home isn't rechecked every single update(!) 118 | // adds in some randomness so that if multiple people are stuck, 119 | // they don't all try to leave at the exact same time 120 | this.atFarm = Math.random() * 40 + 40; 121 | } 122 | } 123 | } 124 | 125 | // If the person has a destination, gotta follow the route to it! 126 | // TODO: We have 3 variables for kinda the same thing but maybe we need them 127 | if (this.hasDestination) { 128 | if (this.destination) { 129 | if (this.route?.length) { 130 | // Head to the first point in the route, and then... remove it when we get there? 131 | const xVariance = Math.random() * 2 - 1; 132 | const yVariance = Math.random() * 2 - 1; 133 | const firstRoutePoint = new Vector( 134 | gridCellSize / 2 + this.route[0].x * gridCellSize + xVariance, 135 | gridCellSize / 2 + this.route[0].y * gridCellSize + yVariance, 136 | ); 137 | 138 | const closeEnough = 2; 139 | const closeEnoughDestination = 1; 140 | 141 | // If a the yurt and farm are adjacent, you don't need to rush... 142 | if (this.originalRoute.length < 3) { 143 | this.dx *= 0.9; 144 | this.dy *= 0.9; 145 | } 146 | 147 | if (this.route.length === 1) { 148 | if ( 149 | Math.abs(this.x - firstRoutePoint.x) < closeEnoughDestination 150 | && Math.abs(this.y - firstRoutePoint.y) < closeEnoughDestination 151 | ) { 152 | if (this.goingHome) { 153 | // TODO: Have like 2 variables for atHome/atFarm/goingHome/goingFarm 154 | this.goingHome = false; 155 | this.atHome = true; 156 | } else { 157 | this.atFarm = 1; 158 | this.farmToVisit.demand -= this.farmToVisit.needyness; 159 | this.farmToVisit.assignedPeople 160 | .splice(this.farmToVisit.assignedPeople.indexOf(this), 1); 161 | // this.farmToVisit.hideWarn(); 162 | // this.animalToVisit.hasPerson = false; 163 | } 164 | this.hasDestination = false; 165 | return; 166 | } 167 | } else if ( 168 | Math.abs(this.x - firstRoutePoint.x) < closeEnough 169 | && Math.abs(this.y - firstRoutePoint.y) < closeEnough 170 | ) { 171 | this.route.shift(); 172 | return; 173 | } 174 | 175 | // Apply a max speed 176 | // Usually < 10 loops with 0.1 and 0.98 177 | while (this.velocity.length() > 0.1) { 178 | this.dx *= 0.98; 179 | this.dy *= 0.98; 180 | } 181 | 182 | const allowedWonkyness = 0.006; 183 | const speed = 0.01; 184 | const vectorToNextpoint = this.position.subtract(firstRoutePoint); 185 | const normalizedVectorToNextPoints = vectorToNextpoint.normalize(); 186 | 187 | if (this.x < firstRoutePoint.x + allowedWonkyness) { 188 | this.dx -= normalizedVectorToNextPoints.x * speed; 189 | } 190 | if (this.x > firstRoutePoint.x - allowedWonkyness) { 191 | this.dx -= normalizedVectorToNextPoints.x * speed; 192 | } 193 | 194 | if (this.y < firstRoutePoint.y + allowedWonkyness) { 195 | this.dy -= normalizedVectorToNextPoints.y * speed; 196 | } 197 | if (this.y > firstRoutePoint.y - allowedWonkyness) { 198 | this.dy -= normalizedVectorToNextPoints.y * speed; 199 | } 200 | // console.log(firstRoutePoint); 201 | } 202 | } 203 | } 204 | 205 | const slowyDistance = 6; 206 | const avoidanceDistance = 1.5; 207 | const turnyness = 0.1; 208 | // Is currently travelling? 209 | // could check velocity instead? 210 | if (this.route?.length > 0) { 211 | const potentialCollisionPeople = people 212 | .filter((otherPerson) => otherPerson !== this && !otherPerson.atHome); 213 | 214 | potentialCollisionPeople.forEach((otherPerson) => { 215 | const distanceBetween = otherPerson.position.distance(this.position); 216 | const nextDistanceBetween = otherPerson.position.distance(this.position.add(this.velocity)); 217 | 218 | if (nextDistanceBetween < distanceBetween) { 219 | if (nextDistanceBetween < avoidanceDistance) { 220 | // TODO: Turn left or right depending on what makes most sense 221 | const vectorBetweenPeople = this.position.subtract(otherPerson.position); 222 | const normalBetweenPeople = vectorBetweenPeople.normalize(); 223 | const turnLeftVector = rotateVector(normalBetweenPeople, (Math.PI / 2)); 224 | const turnLeftVectorScaled = turnLeftVector.scale(turnyness); 225 | this.velocity.set(combineVectors(this.velocity, turnLeftVectorScaled)); 226 | } 227 | } 228 | 229 | const newNextDistanceBetween = otherPerson.position.distance( 230 | this.position.add(this.velocity), 231 | ); 232 | 233 | if (nextDistanceBetween < slowyDistance && this.velocity.length() > 0.06) { 234 | if (newNextDistanceBetween < distanceBetween) { 235 | if (nextDistanceBetween < avoidanceDistance) { 236 | this.dx *= 0.86; 237 | this.dy *= 0.86; 238 | } else { 239 | this.dx *= 0.89; 240 | this.dy *= 0.89; 241 | } 242 | } else { 243 | // Getting further away (still want to go slower than usual) 244 | this.dx *= 0.9; 245 | this.dy *= 0.9; 246 | } 247 | } 248 | }); 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/pointer.js: -------------------------------------------------------------------------------- 1 | import { Vector } from 'kontra'; 2 | import { createSvgElement } from './svg-utils'; 3 | import { svgContainerElement, gridCellSize } from './svg'; 4 | import { Path, drawPaths, paths } from './path'; 5 | import { inventory } from './inventory'; 6 | import { isPastHalfwayInto, getBoardCell } from './cell'; 7 | import { yurts } from './yurt'; 8 | import { gridPointerLayer, pathShadowLayer } from './layers'; 9 | import { removePath } from './remove-path'; 10 | import { ponds } from './pond'; 11 | import { pathTilesIndicator, pathTilesIndicatorCount } from './ui'; 12 | import { 13 | gridShow, gridHide, gridRedShow, gridRedHide, gridRedState, 14 | } from './grid-toggle'; 15 | import { playPathPlacementNote, playOutOfPathsNote } from './audio'; 16 | 17 | let dragStartCell = {}; 18 | let isDragging = false; 19 | 20 | const yurtInCell = (x, y) => yurts.find((yurt) => yurt.x === x && yurt.y === y); 21 | const pondInCell = (x, y) => ponds.find((pond) => pond.points.find((p) => p.x === x && p.y === y)); 22 | const pondPathInCell = (x, y) => paths 23 | .find((path) => path.points[1].x === x && path.points[1].y === y && path.points[1].stone); 24 | 25 | const samePathInBothCell = (x0, y0, x1, y1) => paths.find((path) => ( 26 | ( 27 | (path.points[0].x === x0 && path.points[0].y === y0) 28 | && (path.points[1].x === x1 && path.points[1].y === y1) 29 | ) || ( 30 | (path.points[1].x === x0 && path.points[1].y === y0) 31 | && (path.points[0].x === x1 && path.points[0].y === y1) 32 | ) 33 | )); 34 | 35 | const toSvgCoord = (c) => gridCellSize / 2 + c * gridCellSize; 36 | // The pathDragIndicatorWrapper controls the x/y positioning of the indicator 37 | const pathDragIndicatorWrapper = createSvgElement('g'); 38 | // The pathDragIndicator controls the scale and the path d of the indicator 39 | const pathDragIndicator = createSvgElement('path'); 40 | pathDragIndicator.style.opacity = 0; 41 | pathDragIndicator.style.scale = 0; 42 | pathDragIndicator.style.transition = `all.2s, scale.4s cubic-bezier(.5,2,.5,1)`; 43 | pathDragIndicatorWrapper.append(pathDragIndicator); 44 | pathShadowLayer.append(pathDragIndicatorWrapper); 45 | 46 | const handlePointerdown = (event) => { 47 | event.stopPropagation(); // Prevent hazard area event handling after this 48 | const rect = gridPointerLayer.getBoundingClientRect(); 49 | const { x: cellX, y: cellY } = getBoardCell(event.x - rect.left, event.y - rect.top); 50 | 51 | if (event.buttons === 1 && !gridRedState.locked) { 52 | gridShow(); 53 | 54 | const pondInStartCell = pondInCell(cellX, cellY); 55 | const pondPathInStartCell = pondPathInCell(cellX, cellY); 56 | if (pondInStartCell && !pondPathInStartCell) return; 57 | 58 | isDragging = true; 59 | dragStartCell = { x: cellX, y: cellY }; 60 | 61 | const yurtInStartCell = yurtInCell(dragStartCell.x, dragStartCell.y); 62 | if (yurtInStartCell) { 63 | yurtInStartCell.lift(); 64 | gridPointerLayer.style.cursor = 'grabbing'; 65 | } else { 66 | pathDragIndicator.setAttribute('d', 'M0 0l0 0'); 67 | pathDragIndicatorWrapper.setAttribute('transform', `translate(${toSvgCoord(cellX)} ${toSvgCoord(cellY)})`); 68 | pathDragIndicator.style.opacity = 1; 69 | pathDragIndicator.style.scale = 1.3; 70 | pathDragIndicator.style.transition = `all.2s, scale.4s cubic-bezier(.5,2,.5,1)`; 71 | } 72 | } else if (event.buttons === 2 || gridRedState.locked) { 73 | gridRedShow(); 74 | removePath(cellX, cellY); 75 | } 76 | }; 77 | 78 | const handleHazardPointerdown = () => { 79 | gridRedShow(); 80 | }; 81 | 82 | const handleHazardPointermove = (event) => { 83 | if (event.buttons !== 1) return; 84 | 85 | gridRedShow(); 86 | gridHide(); 87 | }; 88 | 89 | const handleHazardPointerup = () => { 90 | gridRedHide(); 91 | }; 92 | 93 | const handlePointerup = (event) => { 94 | event.stopPropagation(); 95 | const rect = gridPointerLayer.getBoundingClientRect(); 96 | const { x: cellX, y: cellY } = getBoardCell(event.x - rect.left, event.y - rect.top); 97 | const yurtInStartCell = yurtInCell(dragStartCell.x, dragStartCell.y); 98 | const yurtInEndCell = yurtInCell(cellX, cellY); 99 | const pondInStartCell = pondInCell(cellX, cellY); 100 | const pondPathInStartCell = pondPathInCell(cellX, cellY); 101 | 102 | if (pondInStartCell && !pondPathInStartCell) { 103 | gridPointerLayer.style.cursor = 'not-allowed'; 104 | } else if (yurtInEndCell) { 105 | gridPointerLayer.style.cursor = 'grab'; 106 | } else { 107 | gridPointerLayer.style.cursor = 'cell'; 108 | } 109 | 110 | gridHide(); 111 | gridRedHide(); 112 | 113 | pathDragIndicator.style.opacity = 0; 114 | pathDragIndicator.style.scale = 0; 115 | 116 | if (yurtInStartCell) { 117 | yurtInStartCell.place(); 118 | } 119 | 120 | dragStartCell = {}; 121 | isDragging = false; 122 | }; 123 | 124 | const handlePointermove = (event) => { 125 | // Do not trigger hazard area pointermove 126 | event.stopPropagation(); 127 | 128 | const rect = gridPointerLayer.getBoundingClientRect(); 129 | const { x: cellX, y: cellY } = getBoardCell(event.x - rect.left, event.y - rect.top); 130 | 131 | if (event.buttons === 2 || (event.buttons === 1 && gridRedState.locked)) { 132 | gridRedShow(); 133 | removePath(cellX, cellY); 134 | return; 135 | } 136 | 137 | const yurtInStartCell = yurtInCell(dragStartCell.x, dragStartCell.y); 138 | const yurtInEndCell = yurtInCell(cellX, cellY); 139 | const pondInStartCell = pondInCell(cellX, cellY); 140 | const pondPathInStartCell = pondPathInCell(cellX, cellY); 141 | if (pondInStartCell && !pondPathInStartCell) { 142 | gridPointerLayer.style.cursor = 'not-allowed'; 143 | return; 144 | } 145 | 146 | // Assign cursor 147 | if (yurtInEndCell && event.buttons !== 1) { 148 | gridPointerLayer.style.cursor = 'grab'; 149 | } else if ( 150 | event.buttons === 1 151 | && ((yurtInStartCell && yurtInEndCell) || (yurtInStartCell && !yurtInEndCell)) 152 | ) { 153 | gridPointerLayer.style.cursor = 'grabbing'; 154 | } else if (!samePathInBothCell(dragStartCell.x, dragStartCell.y, cellX, cellY)) { 155 | gridPointerLayer.style.cursor = 'cell'; 156 | } 157 | 158 | // Is left click being held down? If not, we don't care 159 | if (event.buttons !== 1) return; 160 | 161 | gridRedHide(); 162 | gridShow(); 163 | 164 | if (!isDragging) return; 165 | 166 | const xDiff = cellX - dragStartCell.x; 167 | const yDiff = cellY - dragStartCell.y; 168 | 169 | const dragStartSvgPx = new Vector({ 170 | x: toSvgCoord(dragStartCell.x), 171 | y: toSvgCoord(dragStartCell.y), 172 | }); 173 | 174 | const L = `${toSvgCoord(xDiff / 2 - 0.5)} ${toSvgCoord(yDiff / 2 - 0.5)}`; 175 | pathDragIndicatorWrapper.setAttribute('transform', `translate(${dragStartSvgPx.x} ${dragStartSvgPx.y})`); 176 | pathDragIndicator.setAttribute('d', `M0 0L${L}`); 177 | pathDragIndicator.style.opacity = 1; 178 | pathDragIndicator.style.scale = 1.3; 179 | 180 | // Same cell or >1 cell apart somehow, do nothing 181 | if ( 182 | (xDiff === 0 && yDiff === 0) 183 | || Math.abs(xDiff) > 1 184 | || Math.abs(yDiff) > 1 185 | ) { 186 | pathDragIndicator.setAttribute('d', 'M0 0L0 0'); 187 | return; 188 | } 189 | 190 | pathDragIndicator.style.transition = `all.2s, scale.4s cubic-bezier(.5,2,.5,1)`; 191 | pathDragIndicator.style.scale = 1; 192 | 193 | // We actually don't want to block building paths in farms :) 194 | // if (farmInCell(cellX, cellY)) { 195 | // dragStartCell = {}; 196 | // isDragging = false; 197 | // return; 198 | // } 199 | 200 | // Have we gone +50% into the new cell? 201 | if (!isPastHalfwayInto({ 202 | pointer: { x: event.x - rect.left, y: event.y - rect.top }, 203 | from: { x: dragStartCell.x, y: dragStartCell.y }, 204 | to: { x: cellX, y: cellY }, 205 | })) return; 206 | 207 | if (yurtInStartCell && !yurtInEndCell) { 208 | yurtInStartCell.rotateTo(cellX, cellY); 209 | dragStartCell = { x: cellX, y: cellY }; 210 | playPathPlacementNote(); 211 | yurtInStartCell.place(); 212 | // pathDragIndicator.setAttribute('d', `M0 0L0 0`); 213 | pathDragIndicator.style.transition = ''; 214 | return; 215 | } if (yurtInEndCell && !yurtInStartCell) { 216 | yurtInEndCell.rotateTo(dragStartCell.x, dragStartCell.y); 217 | // You can't drag through yurt because it was causing too many weird bugs 218 | dragStartCell = {}; 219 | isDragging = false; 220 | playPathPlacementNote(); 221 | yurtInEndCell.place(); 222 | return; 223 | } 224 | 225 | if (yurtInStartCell && yurtInEndCell) { 226 | return; 227 | } 228 | 229 | // No paths check is done after yurt shenanigans 230 | if (inventory.paths <= 0) { 231 | pathTilesIndicator.style.scale = 1.1; 232 | pathTilesIndicatorCount.innerText = '!'; 233 | playOutOfPathsNote(); 234 | 235 | setTimeout(() => { 236 | pathTilesIndicator.style.scale = 1; 237 | pathTilesIndicatorCount.innerText = inventory.paths; 238 | }, 300); 239 | 240 | pathDragIndicator.style.opacity = 0; 241 | dragStartCell = {}; 242 | isDragging = false; 243 | return; 244 | } 245 | 246 | if (samePathInBothCell(dragStartCell.x, dragStartCell.y, cellX, cellY)) { 247 | gridPointerLayer.style.cursor = 'not-allowed'; 248 | return; 249 | } 250 | 251 | playPathPlacementNote(); 252 | const newPath = new Path({ 253 | points: [ 254 | { x: dragStartCell.x, y: dragStartCell.y }, 255 | { x: cellX, y: cellY }, 256 | ], 257 | }); 258 | 259 | inventory.paths--; 260 | pathTilesIndicatorCount.innerText = inventory.paths; 261 | 262 | drawPaths({ 263 | changedCells: 264 | [ 265 | { x: dragStartCell.x, y: dragStartCell.y }, 266 | { x: cellX, y: cellY }, 267 | ], 268 | newPath, 269 | }); 270 | 271 | dragStartCell = { x: cellX, y: cellY }; 272 | pathDragIndicator.style.transition = ''; 273 | }; 274 | 275 | export const initPointer = () => { 276 | svgContainerElement.addEventListener('pointerdown', handleHazardPointerdown); 277 | svgContainerElement.addEventListener('pointermove', handleHazardPointermove); 278 | svgContainerElement.addEventListener('pointerup', handleHazardPointerup); 279 | svgContainerElement.addEventListener('contextmenu', (event) => event.preventDefault()); 280 | gridPointerLayer.addEventListener('pointerdown', handlePointerdown); 281 | gridPointerLayer.addEventListener('pointermove', handlePointermove); 282 | gridPointerLayer.addEventListener('pointerup', handlePointerup); 283 | }; 284 | -------------------------------------------------------------------------------- /src/path.js: -------------------------------------------------------------------------------- 1 | // We pass refs to pathData in forEach, for now it's easier to reassign props directly 2 | /* eslint-disable no-param-reassign */ 3 | import { GameObjectClass } from './modified-kontra/game-object'; 4 | import { pathLayer, pathShadowLayer, rockShadowLayer } from './layers'; 5 | import { gridCellSize } from './svg'; 6 | import { createSvgElement } from './svg-utils'; 7 | import { colors } from './colors'; 8 | import { trees } from './tree'; 9 | 10 | const toSvgCoord = (c) => gridCellSize / 2 + c * gridCellSize; 11 | 12 | export const paths = []; 13 | export const pathSvgWidth = 3; 14 | let connections = []; 15 | let pathsData = []; 16 | let recentlyRemoved = []; 17 | export const getPathsData = () => pathsData; 18 | 19 | export const drawPaths = ({ fadeout, noShadow }) => { 20 | // only care about paths in or next to changedCell 21 | 22 | // const changedPaths = changedCells.length ? paths.filter(path => { 23 | // return changedCells.some(changedCell => ( 24 | // ( 25 | // (path.points[0].x === changedCell.x) && 26 | // (path.points[0].y === changedCell.y) 27 | // ) || ( 28 | // (path.points[1].x === changedCell.x) && 29 | // (path.points[1].y === changedCell.y) 30 | // ) 31 | // )); 32 | // }) : paths; 33 | 34 | // console.log(changedCells); 35 | 36 | const changedPaths = paths; 37 | 38 | // go through each cell and look for paths with either end on both points? 39 | connections = []; 40 | 41 | // Compare each path to every other path 42 | changedPaths.forEach((path1) => { 43 | changedPaths.forEach((path2) => { 44 | if (path1 === path2) return; 45 | 46 | // If there is already this pair of paths in the connections list, skip 47 | if (connections.find((c) => c.path1 === path2 && c.path2 === path1)) { 48 | return; 49 | } 50 | 51 | // If either path has the 'do not connect anything to me' flag then skip 52 | if (path1.noConnect || path2.noConnect) return; 53 | 54 | if ( 55 | path1.points[0].x === path2.points[0].x 56 | && path1.points[0].y === path2.points[0].y 57 | ) { 58 | connections.push({ 59 | path1, 60 | path2, 61 | points: [ 62 | path1.points[1], 63 | path1.points[0], 64 | path2.points[1], 65 | ], 66 | }); 67 | } else if ( 68 | path1.points[0].x === path2.points[1].x 69 | && path1.points[0].y === path2.points[1].y 70 | ) { 71 | connections.push({ 72 | path1, 73 | path2, 74 | points: [ 75 | path1.points[1], 76 | path1.points[0], 77 | path2.points[0], 78 | ], 79 | }); 80 | } else if ( 81 | path1.points[1].x === path2.points[0].x 82 | && path1.points[1].y === path2.points[0].y 83 | ) { 84 | connections.push({ 85 | path1, 86 | path2, 87 | points: [ 88 | path1.points[0], 89 | path1.points[1], 90 | path2.points[1], 91 | ], 92 | }); 93 | } else if ( 94 | path1.points[1].x === path2.points[1].x 95 | && path1.points[1].y === path2.points[1].y 96 | ) { 97 | connections.push({ 98 | path1, 99 | path2, 100 | points: [ 101 | path1.points[0], 102 | path1.points[1], 103 | path2.points[0], 104 | ], 105 | }); 106 | } 107 | }); 108 | }); 109 | 110 | const newPathsData = []; 111 | 112 | connections.forEach((connection) => { 113 | const { path1, path2, points } = connection; 114 | 115 | // Starting point 116 | const M = `M${toSvgCoord(points[0].x)} ${toSvgCoord(points[0].y)}`; 117 | 118 | // A line that goes from 1st cell to the border between it and middle cell 119 | const Lx1 = toSvgCoord(points[0].x + (points[1].x - points[0].x) / 2); 120 | const Ly1 = toSvgCoord(points[0].y + (points[1].y - points[0].y) / 2); 121 | const L1 = `L${Lx1} ${Ly1}`; 122 | 123 | // A line that goes from the end of the curve (Q) to the 2nd point 124 | const Lx2 = toSvgCoord(points[2].x); 125 | const Ly2 = toSvgCoord(points[2].y); 126 | const L2 = `L${Lx2} ${Ly2}`; 127 | 128 | const Qx1 = toSvgCoord(points[1].x); 129 | const Qx2 = toSvgCoord(points[1].y); 130 | const Qx = toSvgCoord(points[1].x + (points[2].x - points[1].x) / 2); 131 | const Qy = toSvgCoord(points[1].y + (points[2].y - points[1].y) / 2); 132 | const Q = `Q${Qx1} ${Qx2} ${Qx} ${Qy}`; 133 | 134 | // Only draw the starty bit if it's not the center of another connection 135 | const start = connections 136 | .find((c) => points[0].x === c.points[1].x && points[0].y === c.points[1].y) 137 | ? `M${Lx1} ${Ly1}` 138 | : `${M}${L1}`; 139 | const end = connections 140 | .find((c) => points[2].x === c.points[1].x && points[2].y === c.points[1].y) 141 | ? '' 142 | : L2; 143 | 144 | newPathsData.push({ 145 | path1, 146 | path2, 147 | d: `${start}${Q}${end}`, 148 | }); 149 | }); 150 | 151 | // What about paths that have 0 connections ??? 152 | changedPaths.forEach((path) => { 153 | const connected = connections.find((c) => c.path1 === path || c.path2 === path); 154 | 155 | if (!connected && !path.noConnect) { 156 | const { points } = path; 157 | // this path has no connections, need to add it to the list as a little 2x1 path 158 | const M = `${toSvgCoord(points[0].x)} ${toSvgCoord(points[0].y)}`; 159 | const L = `${toSvgCoord(points[1].x)} ${toSvgCoord(points[1].y)}`; 160 | newPathsData.push({ 161 | path, 162 | d: `M${M}L${L}`, 163 | M, 164 | L, 165 | }); 166 | } 167 | }); 168 | 169 | newPathsData.forEach((newPathData) => { 170 | pathsData.forEach((oldPathData) => { 171 | // it's the same path, skip 172 | // if (newPathData === oldPathData) return; 173 | 174 | // it's the same connection (set of two specific paths) as before 175 | const samePath = newPathData.path && newPathData.path === oldPathData.path; 176 | const samePath1 = newPathData.path1 && newPathData.path1 === oldPathData.path1; 177 | const samePath2 = newPathData.path2 && newPathData.path2 === oldPathData.path2; 178 | if ((samePath) || (samePath1 && samePath2)) { 179 | newPathData.svgElement = oldPathData.svgElement; 180 | newPathData.svgElementStoneShadow = oldPathData.svgElementStoneShadow; 181 | newPathData.svgElementShadow = oldPathData.svgElementShadow; 182 | 183 | // The two path datas are different, this connection/path aaah needs updating 184 | if (newPathData.d !== oldPathData.d) { 185 | oldPathData.d = newPathData.d; 186 | newPathData.svgElement.setAttribute('d', newPathData.d); 187 | newPathData.svgElementStoneShadow?.setAttribute('d', newPathData.d); 188 | } 189 | } 190 | }); 191 | 192 | // Remove old path SVGs 193 | pathsData.forEach((oldPathData) => { 194 | if (!newPathsData.find((newPathData2) => oldPathData.d === newPathData2.d)) { 195 | if (oldPathData.path) { 196 | // if (changedPaths.includes(oldPathData.path)) { 197 | if (fadeout && oldPathData.path && oldPathData.path.points[0].fixed) { 198 | setTimeout(() => { 199 | oldPathData.svgElement.remove(); 200 | oldPathData.svgElementStoneShadow?.remove(); 201 | }, 500); 202 | } else { 203 | oldPathData.svgElement.remove(); 204 | oldPathData.svgElementStoneShadow?.remove(); 205 | } 206 | // } 207 | } 208 | } 209 | }); 210 | 211 | // There's a new bit of path data that needs drawing 212 | if (!newPathData.svgElement) { 213 | newPathData.svgElement = createSvgElement('path'); 214 | newPathData.svgElement.setAttribute('d', newPathData.d); 215 | newPathData.svgElement.style.transition = `all .4s, opacity .2s`; 216 | 217 | if (newPathData.path?.points[0].stone 218 | || newPathData.path?.points[1].stone 219 | || newPathData.path1?.points[0].stone 220 | || newPathData.path1?.points[1].stone 221 | || newPathData.path2?.points[0].stone 222 | || newPathData.path2?.points[1].stone) { 223 | newPathData.svgElement.style.strokeDasharray = '0 3px'; 224 | newPathData.svgElement.style.strokeWidth = '2px'; 225 | newPathData.svgElement.style.stroke = '#bbb'; 226 | 227 | // Chrome does not support sub-pixel CSS filters, so instead of this, we need another path 228 | // newPathData.svgElement.style.filter = `drop-shadow(.3px .3px ${colors.shade2})`; 229 | 230 | newPathData.svgElementStoneShadow = createSvgElement('path'); 231 | newPathData.svgElementStoneShadow.setAttribute('d', newPathData.d); 232 | newPathData.svgElementStoneShadow.style.transition = `all .4s opacity .2s`; 233 | newPathData.svgElementStoneShadow.style.strokeDasharray = '0 3px'; 234 | newPathData.svgElementStoneShadow.style.strokeWidth = '2px'; 235 | newPathData.svgElementStoneShadow.style.stroke = colors.black; 236 | 237 | rockShadowLayer.append(newPathData.svgElementStoneShadow); 238 | } 239 | 240 | pathLayer.append(newPathData.svgElement); 241 | 242 | // Only transition "new new" single paths 243 | const pathInSameCellRecentlyRemoved = newPathData.path && recentlyRemoved.some((r) => ( 244 | ( 245 | r.x === newPathData.path.points[0].x 246 | && r.y === newPathData.path.points[0].y 247 | ) || ( 248 | r.x === newPathData.path.points[1].x 249 | && r.y === newPathData.path.points[1].y 250 | ) 251 | )); 252 | 253 | const isYurtPath = newPathData.path?.points[0].fixed; 254 | 255 | if (newPathData.path === undefined || !pathInSameCellRecentlyRemoved || isYurtPath) { 256 | newPathData.svgElement.setAttribute('stroke-width', 0); 257 | newPathData.svgElement.setAttribute('opacity', 0); 258 | newPathData.svgElement.style.willChange = `stroke-width, opacity`; 259 | 260 | if (isYurtPath) { 261 | newPathData.svgElement.setAttribute('d', `M${newPathData.M}L${newPathData.M}`); 262 | 263 | setTimeout(() => { 264 | newPathData.svgElement.setAttribute('d', `M${newPathData.M}L${newPathData.L}`); 265 | }, 20); 266 | } 267 | 268 | if (!noShadow) { 269 | newPathData.svgElementShadow = createSvgElement('path'); 270 | newPathData.svgElementShadow.setAttribute('d', newPathData.d); 271 | pathShadowLayer.append(newPathData.svgElementShadow); 272 | 273 | // After transition complete, we don't need the shadow anymore 274 | setTimeout(() => { 275 | newPathData.svgElementShadow?.remove(); 276 | newPathData.svgElement.style.willChange = ''; 277 | }, 500); 278 | } 279 | 280 | setTimeout(() => { 281 | newPathData.svgElement.removeAttribute('stroke-width'); 282 | newPathData.svgElement.setAttribute('opacity', 1); 283 | }, 20); 284 | } 285 | } 286 | }); 287 | 288 | pathsData = [...newPathsData]; 289 | recentlyRemoved = []; 290 | }; 291 | 292 | export class Path extends GameObjectClass { 293 | constructor(properties) { 294 | const { points } = properties; 295 | 296 | super({ 297 | ...properties, 298 | points, 299 | }); 300 | 301 | trees 302 | .filter((t) => this.points.some((p) => p.x === t.x && p.y === t.y)) 303 | .forEach((tree) => tree.remove()); 304 | 305 | paths.push(this); 306 | } 307 | 308 | remove() { 309 | pathsData = pathsData.filter((p) => { 310 | if (p.path === this || p.path1 === this || p.path2 === this) { 311 | p.svgElement.setAttribute('opacity', 0); 312 | p.svgElement.setAttribute('stroke-width', 0); 313 | p.svgElementStoneShadow?.setAttribute('opacity', 0); 314 | p.svgElementStoneShadow?.setAttribute('stroke-width', 0); 315 | 316 | setTimeout(() => { 317 | p.svgElement.remove(); 318 | p.svgElementStoneShadow?.remove(); 319 | }, 500); 320 | return false; 321 | } 322 | 323 | return true; 324 | }); 325 | 326 | // Remove from paths array 327 | paths.splice(paths.findIndex((p) => p === this), 1); 328 | 329 | recentlyRemoved.push( 330 | { x: this.points[0].x, y: this.points[0].y }, 331 | { x: this.points[1].x, y: this.points[1].y }, 332 | ); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/farm.js: -------------------------------------------------------------------------------- 1 | import { GameObjectClass } from './modified-kontra/game-object'; 2 | import { gridCellSize } from './svg'; 3 | import { createSvgElement } from './svg-utils'; 4 | import { 5 | gridBlockLayer, fenceLayer, fenceShadowLayer, pinLayer, 6 | } from './layers'; 7 | import { gridLineThickness } from './grid'; 8 | import { Path, drawPaths } from './path'; 9 | import { colors } from './colors'; 10 | import { people } from './person'; 11 | import { findRoute } from './find-route'; 12 | import { playWarnNote } from './audio'; 13 | 14 | export const farms = []; 15 | 16 | // TODO: Landscape and portrait fences? Square or circle fences? 17 | const roundness = 2; 18 | const fenceLineThickness = 1; 19 | 20 | export class Farm extends GameObjectClass { 21 | constructor(properties) { 22 | const { relativePathPoints } = properties; 23 | super(properties); 24 | this.delay = this.delay ?? 0; 25 | this.demand = 0; 26 | this.totalUpdates = 0; 27 | this.circumference = this.width * gridCellSize * 2 + this.height * gridCellSize * 2; 28 | this.numIssues = 0; 29 | this.assignedPeople = []; 30 | this.points = []; 31 | 32 | for (let w = 0; w < this.width; w++) { 33 | for (let h = 0; h < this.height; h++) { 34 | this.points.push({ x: this.x + w, y: this.y + h }); 35 | } 36 | } 37 | 38 | if (relativePathPoints) { 39 | setTimeout(() => { 40 | // TODO: Make pathPoints a required variable? 41 | this.startPath = new Path({ 42 | points: [ 43 | { 44 | x: this.x + relativePathPoints[0].x, 45 | y: this.y + relativePathPoints[0].y, 46 | fixed: relativePathPoints[0].fixed, 47 | stone: relativePathPoints[0].stone, 48 | }, 49 | { 50 | x: this.x + relativePathPoints[1].x, 51 | y: this.y + relativePathPoints[1].y, 52 | fixed: relativePathPoints[1].fixed, 53 | stone: relativePathPoints[1].stone, 54 | }, 55 | ], 56 | }); 57 | 58 | drawPaths({}); 59 | }, 1500 + properties.delay); // Can't prevent path overlap soon after spawning due to this 60 | } 61 | 62 | farms.push(this); 63 | setTimeout(() => { 64 | this.addToSvg(); 65 | }, properties.delay); 66 | } 67 | 68 | addAnimal(animal) { 69 | this.addChild(animal); 70 | animal.addToSvg(); 71 | } 72 | 73 | assignWarn() { 74 | const adults = this.children.filter((c) => !c.isBaby); 75 | const notWarnedAnimals = adults.filter((c) => !c.hasWarn); 76 | const warnedAnimals = adults.filter((c) => c.hasWarn); 77 | 78 | if (this.hasWarn) { 79 | if (this.numIssues <= adults.length) { 80 | this.hideWarn(); 81 | } else { 82 | this.children.forEach((c) => c.hideWarn()); 83 | } 84 | } else { 85 | this.toggleWarn(this.numIssues > adults.length); 86 | 87 | if (warnedAnimals.length && this.numIssues < warnedAnimals.length) { 88 | warnedAnimals[Math.floor(Math.random() * warnedAnimals.length)].hideWarn(); 89 | } 90 | 91 | if (notWarnedAnimals.length && this.numIssues > adults.length - notWarnedAnimals.length) { 92 | notWarnedAnimals[Math.floor(Math.random() * notWarnedAnimals.length)].showWarn(); 93 | } 94 | } 95 | } 96 | 97 | update(gameStarted, updateCount) { 98 | // Don't actually update while the farm is transitioning-in 99 | if (this.appearing) return; 100 | 101 | if (gameStarted) { 102 | this.numIssues = Math.floor(this.demand / this.needyness); 103 | // this.demand += 20; // Extra demand for testing gameover screen etc. 104 | this.demand += (this.children.length - 1) + ((updateCount * updateCount) / 1e9); 105 | // console.log((this.children.length - 1) + ((updateCount * updateCount) / 1e9)); 106 | 107 | if (this.hasWarn) { 108 | this.updateWarn(); 109 | } 110 | 111 | this.assignWarn(); 112 | } 113 | 114 | this.children.forEach((animal) => animal.update(gameStarted)); 115 | 116 | for (let i = 0; i < this.numIssues; i++) { 117 | if (this.assignedPeople.length >= this.numIssues) return; 118 | // Find someone sitting around doing nothing 119 | const atHomePeopleOfSameType = people 120 | .filter((person) => person.atHome && person.type === this.type); 121 | 122 | if (atHomePeopleOfSameType.length === 0) return; 123 | 124 | // Assign whoever is closest to this farm, to this animal(?) 125 | let closestPerson = atHomePeopleOfSameType[0]; 126 | 127 | let bestRoute = null; 128 | 129 | for (let j = 0; j < atHomePeopleOfSameType.length; j++) { 130 | const thisRoute = findRoute({ 131 | from: { 132 | x: atHomePeopleOfSameType[j].parent.x, 133 | y: atHomePeopleOfSameType[j].parent.y, 134 | }, 135 | to: this.points, 136 | }); 137 | 138 | // If there is no current best route... this is faster than nothing! 139 | if (!bestRoute) { 140 | bestRoute = thisRoute; 141 | closestPerson = atHomePeopleOfSameType[j]; 142 | } 143 | 144 | // If this persons route has fewer nodes, it's probably faster. 145 | if (thisRoute && thisRoute.length < bestRoute.length) { 146 | bestRoute = thisRoute; 147 | closestPerson = atHomePeopleOfSameType[j]; 148 | } 149 | 150 | // If this persons route has the same number of nodes, but fewer diagonals? 151 | // Re-calculating these distances is not particularly costly, because 152 | // it's rare that two routes will have the exact same number of nodes 153 | if (thisRoute && thisRoute.length === bestRoute.length) { 154 | // If this person is from the same yurt as the other person don't check 155 | if (atHomePeopleOfSameType[j].parent !== closestPerson.parent) { 156 | const bestDistance = bestRoute.reduce((acc, curr) => acc + (curr.distance ?? 0), 0); 157 | const thisDistance = thisRoute.reduce((acc, curr) => acc + (curr.distance ?? 0), 0); 158 | 159 | if (thisDistance < bestDistance) { 160 | bestRoute = thisRoute; 161 | closestPerson = atHomePeopleOfSameType[j]; 162 | } 163 | } 164 | } 165 | } 166 | 167 | if (bestRoute) { 168 | closestPerson.destination = bestRoute.at(-1); 169 | closestPerson.hasDestination = true; 170 | closestPerson.route = bestRoute; 171 | closestPerson.originalRoute = [...bestRoute]; 172 | closestPerson.atHome = false; // Leave home! 173 | this.assignedPeople.push(closestPerson); 174 | closestPerson.farmToVisit = this; 175 | } 176 | } 177 | } 178 | 179 | render() { 180 | this.children.forEach((animal) => animal.render()); 181 | } 182 | 183 | addToSvg() { 184 | const x = this.x * gridCellSize + fenceLineThickness / 2 + gridLineThickness / 2; 185 | const y = this.y * gridCellSize + fenceLineThickness / 2 + gridLineThickness / 2; 186 | const svgWidth = gridCellSize * this.width - fenceLineThickness - gridLineThickness; 187 | const svgHeight = gridCellSize * this.height - fenceLineThickness - gridLineThickness; 188 | 189 | if (this.type !== colors.fish) { 190 | const gridBlock = createSvgElement('rect'); 191 | gridBlock.style.width = svgWidth; 192 | gridBlock.style.height = svgHeight; 193 | gridBlock.setAttribute('rx', roundness); 194 | gridBlock.setAttribute('transform', `translate(${x},${y})`); 195 | gridBlock.style.opacity = 0; 196 | gridBlock.style.transition = 'opacity.8s'; 197 | gridBlock.style.willChange = 'opacity'; 198 | gridBlock.setAttribute('fill', colors.grass); 199 | gridBlockLayer.append(gridBlock); 200 | setTimeout(() => gridBlock.style.opacity = 1, 1000); 201 | setTimeout(() => gridBlock.style.willChange = '', 2000); 202 | } 203 | 204 | const fence = createSvgElement('rect'); 205 | fence.setAttribute('width', svgWidth); 206 | fence.setAttribute('height', svgHeight); 207 | fence.setAttribute('rx', roundness); 208 | fence.setAttribute('transform', `translate(${x},${y})`); 209 | fence.setAttribute('stroke', this.fenceColor); 210 | fence.setAttribute('stroke-dasharray', this.circumference); // Math.PI * 2 + a bit 211 | fence.setAttribute('stroke-dashoffset', this.circumference); 212 | fence.style.transition = `all 1s`; 213 | fenceLayer.append(fence); 214 | 215 | const shadow = createSvgElement('rect'); 216 | // TODO: Landscape and portrait fences? Square or circle fences? 217 | shadow.setAttribute('width', svgWidth); 218 | shadow.setAttribute('height', svgHeight); 219 | shadow.setAttribute('rx', roundness); 220 | shadow.style.transform = `translate(${x - 0.5}px,${y - 0.5}px)`; 221 | shadow.style.willChange = 'stroke-dashoffset, transform'; 222 | shadow.setAttribute('stroke-dasharray', this.circumference); // Math.PI * 2 + a bit 223 | shadow.setAttribute('stroke-dashoffset', this.circumference); 224 | shadow.style.transition = `stroke-dashoffset 1s, transform .5s`; 225 | fenceShadowLayer.append(shadow); 226 | 227 | setTimeout(() => { 228 | fence.setAttribute('stroke-dashoffset', 0); 229 | shadow.setAttribute('stroke-dashoffset', 0); 230 | }, 100); 231 | 232 | setTimeout(() => { 233 | shadow.style.transform = `translate(${x}px,${y}px)`; 234 | }, 1000); 235 | 236 | this.pinSvg = createSvgElement('g'); 237 | this.pinSvg.translate = `${x + svgWidth / 2}px, ${y + svgHeight / 2 + 1.5}px`; 238 | this.pinSvg.style.willChange = `opacity, transform`; 239 | this.pinSvg.style.transition = `all .8s cubic-bezier(.5, 2, .5, 1)`; 240 | this.pinSvg.style.transformOrigin = 'bottom'; 241 | this.pinSvg.style.transformBox = 'fill-box'; 242 | this.pinSvg.style.opacity = 0; 243 | this.pinSvg.style.transform = `translate(${this.pinSvg.translate}) scale(0)`; 244 | pinLayer.append(this.pinSvg); 245 | 246 | this.pinBubble = createSvgElement('path'); 247 | this.pinBubble.setAttribute('fill', '#fff'); 248 | this.pinBubble.setAttribute('d', 'm6 6-2-2a3 3 0 1 1 4 0Z'); 249 | this.pinBubble.setAttribute('transform', 'translate(-9 -9) scale(1.5)'); 250 | this.pinSvg.append(this.pinBubble); 251 | 252 | this.warnCircleBg = createSvgElement('circle'); 253 | this.warnCircleBg.setAttribute('fill', 'none'); 254 | this.warnCircleBg.setAttribute('stroke-width', '2'); 255 | this.warnCircleBg.setAttribute('stroke-linecap', 'square'); // TODO: Remove parent 256 | this.warnCircleBg.setAttribute('r', 2); 257 | this.warnCircleBg.setAttribute('stroke', colors.ui); 258 | this.warnCircleBg.setAttribute('opacity', 0.2); 259 | this.warnCircleBg.setAttribute('transform', 'scale(1.2) translate(0 -5.3)'); 260 | this.pinSvg.append(this.warnCircleBg); 261 | 262 | this.warnCircle = createSvgElement('circle'); 263 | this.warnCircle.setAttribute('fill', 'none'); 264 | this.warnCircle.setAttribute('stroke-width', '2'); 265 | this.warnCircle.setAttribute('stroke-linecap', 'butt'); // TODO: Remove parent 266 | this.warnCircle.setAttribute('r', 2); 267 | this.warnCircle.setAttribute('stroke', colors.red); 268 | this.warnCircle.style.willChange = 'stroke-dashoffset'; 269 | this.warnCircle.style.transition = 'stroke-dashoffset.5s'; 270 | this.warnCircle.setAttribute('stroke-dasharray', 12.56); // Math.PI * 4ish 271 | this.warnCircle.setAttribute('stroke-dashoffset', 12.56); 272 | this.warnCircle.style.transition = 'stroke-dashoffset.3s.1s'; 273 | this.warnCircle.setAttribute('transform', 'scale(1.2) translate(0 -5.3) rotate(-90)'); 274 | this.pinSvg.append(this.warnCircle); 275 | 276 | this.pinSvg.style.opacity = 1; 277 | } 278 | 279 | showWarn() { 280 | this.hasWarn = true; 281 | this.pinSvg.style.opacity = 1; 282 | this.warnCircle.style.transition = 'stroke-dashoffset.4s.8s'; 283 | this.pinSvg.style.transform = `translate(${this.pinSvg.translate}) scale(1)`; 284 | this.pinSvg.style.transition = `all .8s cubic-bezier(.5,2,.5,1)`; 285 | playWarnNote(this.type); 286 | 287 | setTimeout(() => { 288 | this.warnCircle.style.transition = 'stroke-dashoffset.4s'; 289 | }, 1000); 290 | } 291 | 292 | hideWarn() { 293 | this.hasWarn = false; 294 | this.pinSvg.style.opacity = 0; 295 | this.warnCircle.style.transition = `stroke-dashoffset .3s`; 296 | this.pinSvg.style.transform = `translate(${this.pinSvg.translate}) scale(0)`; 297 | this.pinSvg.style.transition = `all .8s cubic-bezier(.5, 2, .5, 1) .4s`; 298 | } 299 | 300 | toggleWarn(toggle) { 301 | if (toggle) { 302 | this.showWarn(); 303 | } else { 304 | this.hideWarn(); 305 | } 306 | } 307 | 308 | updateWarn() { 309 | const fullCircle = 12.56; // Math.PI * 4ish 310 | const adults = this.children.filter((c) => !c.isBaby); 311 | const maxOverflow = adults.length * 2; 312 | const numOverflowIssues = this.numIssues - adults.length; 313 | const dashoffset = fullCircle - ((fullCircle / maxOverflow) * numOverflowIssues); 314 | 315 | this.warnCircle.setAttribute('stroke-dashoffset', dashoffset); 316 | 317 | if (this.prevNumOverflowIssues < numOverflowIssues) { 318 | playWarnNote(this.type); 319 | this.pinSvg.style.transform = `translate(${this.pinSvg.translate}) scale(1.2)`; 320 | 321 | setTimeout(() => { 322 | this.pinSvg.style.transform = `translate(${this.pinSvg.translate}) scale(1)`; 323 | }, 200); 324 | } 325 | 326 | this.prevNumOverflowIssues = numOverflowIssues; 327 | 328 | if (numOverflowIssues === maxOverflow) { 329 | this.isAlive = false; 330 | } 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/ui.js: -------------------------------------------------------------------------------- 1 | import { createSvgElement } from './svg-utils'; 2 | import { emojiOx } from './ox-emoji'; 3 | import { emojiGoat } from './goat-emoji'; 4 | import { emojiFish } from './fish-emoji'; 5 | import { colors } from './colors'; 6 | import { createElement } from './create-element'; 7 | 8 | export const uiContainer = createElement(); 9 | 10 | // Animal score counters (for incrementing) and their wrappers (for show/hiding) 11 | export const oxCounterWrapper = createElement(); 12 | export const oxCounter = createElement(); 13 | export const goatCounterWrapper = createElement(); 14 | export const goatCounter = createElement(); 15 | export const fishCounterWrapper = createElement(); 16 | export const fishCounter = createElement(); 17 | 18 | export const scoreCounters = createElement(); 19 | 20 | export const clock = createElement(); 21 | export const clockMonth = createElement(); 22 | 23 | export const pathTilesIndicator = createElement(); 24 | export const pathTilesIndicatorCount = createElement(); 25 | 26 | export const pauseButton = createElement('button'); 27 | export const pauseSvgPath = createSvgElement('path'); 28 | 29 | // Odd one out because can't put divs in an svg 30 | export const clockHand = createSvgElement('path'); 31 | 32 | export const gridToggleButton = createElement('button'); 33 | export const gridToggleSvg = createSvgElement('svg'); 34 | export const gridToggleSvgPath = createSvgElement('path'); 35 | export const gridToggleTooltip = createElement(); 36 | 37 | export const gridRedToggleButton = createElement('button'); 38 | export const gridRedToggleSvg = createSvgElement('svg'); 39 | export const gridRedToggleSvgPath = createSvgElement('path'); 40 | export const gridRedToggleTooltip = createElement(); 41 | 42 | export const soundToggleButton = createElement('button'); 43 | export const soundToggleSvg = createSvgElement('svg'); 44 | export const soundToggleSvgPath = createSvgElement('path'); 45 | export const soundToggleSvgPathX = createSvgElement('path'); 46 | export const soundToggleTooltip = createElement(); 47 | 48 | export const initUi = () => { 49 | const styles = createElement('style'); 50 | // body has user-select: none; to prevent text being highlighted. 51 | // ui black and shade colours inlined to make things smaller maybe 52 | styles.innerText = ` 53 | body { 54 | position: relative; 55 | font-weight: 700; 56 | font-family: system-ui; 57 | color: ${colors.ui}; 58 | margin: 0; 59 | width: 100vw; 60 | height: 100vh; 61 | user-select: none; 62 | } 63 | button { 64 | font-weight: 700; 65 | font-family: system-ui; 66 | color: ${colors.ui}; 67 | border: none; 68 | padding: 0 20px; 69 | font-size: 32px; 70 | height: 56px; 71 | border-radius: 64px; 72 | background: ${colors.yurt}; 73 | transition: all .2s, bottom .5s, right .5s, opacity 1s; 74 | box-shadow: 0 0 0 1px ${colors.shade}; 75 | } 76 | button:hover { 77 | box-shadow: 4px 4px 0 1px ${colors.shade}; 78 | } 79 | button:active { 80 | transform: scale(.95); 81 | box-shadow: 0 0 0 1px ${colors.shade}; 82 | } 83 | u, abbr { 84 | text-decoration-thickness: 2px; 85 | text-underline-offset: 2px; 86 | } 87 | `; 88 | document.head.append(styles); 89 | 90 | uiContainer.style.cssText = ` 91 | position: absolute; 92 | inset: 0; 93 | display: grid; 94 | overflow: hidden; 95 | pointer-events: none 96 | `; 97 | uiContainer.style.zIndex = 1; 98 | document.body.append(uiContainer); 99 | 100 | scoreCounters.style.cssText = `display:flex;position:absolute;top:16px;left:16px;`; 101 | scoreCounters.style.transition = `opacity 1s`; 102 | scoreCounters.style.opacity = 0; 103 | 104 | oxCounterWrapper.style.cssText = `display:flex;align-items:center;gap:8px;transition:width 1s,opacity 1s 1s`; 105 | const oxCounterEmoji = emojiOx(); 106 | oxCounterWrapper.style.width = 0; 107 | oxCounterWrapper.style.opacity = 0; 108 | oxCounterEmoji.style.width = '48px'; 109 | oxCounterEmoji.style.height = '48px'; 110 | oxCounterWrapper.append(oxCounterEmoji, oxCounter); 111 | 112 | goatCounterWrapper.style.cssText = `display:flex;align-items:center;gap:8px;transition:width 1s,opacity 1s 1s`; 113 | const goatCounterEmoji = emojiGoat(); 114 | goatCounterWrapper.style.width = 0; 115 | goatCounterWrapper.style.opacity = 0; 116 | goatCounterEmoji.style.width = '48px'; 117 | goatCounterEmoji.style.height = '48px'; 118 | goatCounterWrapper.append(goatCounterEmoji, goatCounter); 119 | 120 | fishCounterWrapper.style.cssText = `display:flex;align-items:center;gap:8px;transition:width 1s,opacity 1s 1s`; 121 | const fishCounterEmoji = emojiFish(); 122 | fishCounterWrapper.style.width = 0; 123 | fishCounterWrapper.style.opacity = 0; 124 | fishCounterEmoji.style.width = '48px'; 125 | fishCounterEmoji.style.height = '48px'; 126 | fishCounterWrapper.append(fishCounterEmoji, fishCounter); 127 | 128 | scoreCounters.append(oxCounterWrapper, goatCounterWrapper, fishCounterWrapper); 129 | 130 | clock.style.cssText = ` 131 | position: absolute; 132 | display: grid; 133 | top: 16px; 134 | right: 16px; 135 | place-items: center; 136 | border-radius: 64px; 137 | background: ${colors.ui} 138 | `; 139 | clock.style.width = '80px'; 140 | clock.style.height = '80px'; 141 | clock.style.opacity = 0; 142 | clock.style.transition = `opacity 1s`; 143 | 144 | const clockSvg = createSvgElement('svg'); 145 | clockSvg.setAttribute('stroke-linejoin', 'round'); 146 | clockSvg.setAttribute('stroke-linecap', 'round'); 147 | clockSvg.setAttribute('viewBox', '0 0 16 16'); 148 | clockSvg.style.width = '80px'; 149 | clockSvg.style.height = '80px'; 150 | 151 | for (let i = 75; i < 350; i += 25) { 152 | const dot = createSvgElement('path'); 153 | dot.setAttribute('fill', 'none'); 154 | dot.setAttribute('stroke', '#eee'); 155 | dot.setAttribute('transform-origin', 'center'); 156 | dot.setAttribute('d', 'm8 14.5 0 0'); 157 | dot.style.transform = `rotate(${i}grad)`; 158 | clockSvg.append(dot); 159 | } 160 | 161 | clockHand.setAttribute('stroke', '#eee'); 162 | clockHand.setAttribute('transform-origin', 'center'); 163 | clockHand.setAttribute('d', 'm8 4 0 4'); 164 | clockSvg.append(clockHand); 165 | 166 | clockMonth.style.cssText = `position:absolute;bottom:8px;color:#eee`; 167 | 168 | clock.append(clockSvg, clockMonth); 169 | 170 | pathTilesIndicator.style.cssText = ` 171 | position: absolute; 172 | display: grid; 173 | place-items: center; 174 | place-self: center; 175 | bottom: 20px; 176 | border-radius: 20px; 177 | background: ${colors.ui}; 178 | `; 179 | if (document.body.scrollHeight < 500) { 180 | pathTilesIndicator.style.left = '20px'; 181 | } else { 182 | pathTilesIndicator.style.left = ''; 183 | } 184 | addEventListener('resize', () => { 185 | if (document.body.scrollHeight < 500) { 186 | pathTilesIndicator.style.left = '20px'; 187 | } else { 188 | pathTilesIndicator.style.left = ''; 189 | } 190 | }); 191 | pathTilesIndicator.style.transform = 'rotate(-45deg)'; 192 | pathTilesIndicator.style.opacity = 0; 193 | pathTilesIndicator.style.transition = `scale .4s cubic-bezier(.5, 2, .5, 1), opacity 1s`; 194 | pathTilesIndicator.style.width = '72px'; 195 | pathTilesIndicator.style.height = '72px'; 196 | pathTilesIndicatorCount.style.cssText = ` 197 | position: absolute; 198 | display: grid; 199 | place-items: center; 200 | border-radius: 64px; 201 | border: 6px solid ${colors.ui}; 202 | transform: translate(28px,28px) rotate(45deg); 203 | font-size: 18px; 204 | background: #eee; 205 | transition: all.5s; 206 | }`; 207 | pathTilesIndicatorCount.style.width = '28px'; 208 | pathTilesIndicatorCount.style.height = '28px'; 209 | const pathTilesSvg = createSvgElement('svg'); 210 | pathTilesSvg.setAttribute('viewBox', '0 0 18 18'); 211 | pathTilesSvg.style.width = '54px'; 212 | pathTilesSvg.style.height = '54px'; 213 | pathTilesSvg.style.transform = 'rotate(45deg)'; 214 | const pathTilesSvgPath = createSvgElement('path'); 215 | pathTilesSvgPath.setAttribute('fill', 'none'); 216 | pathTilesSvgPath.setAttribute('stroke', '#eee'); 217 | // pathTilesPath.setAttribute('stroke-linejoin', 'round'); 218 | pathTilesSvgPath.setAttribute('stroke-linecap', 'round'); 219 | pathTilesSvgPath.setAttribute('stroke-width', 2); 220 | pathTilesSvgPath.setAttribute('d', 'M11 1h-3q-2 0-2 2t2 2h4q2 0 2 2t-2 2h-6q-2 0-2 2t2 2h4q2 0 2 2t-2 2h-3'); 221 | pathTilesSvg.append(pathTilesSvgPath); 222 | // pathTilesIndicatorInner.append(pathTilesSvg); 223 | // pathTilesIndicatorInner.style.width = '64px'; 224 | // pathTilesIndicatorInner.style.height = '64px'; 225 | // pathTilesIndicatorInner.style.borderRadius = '16px'; // The only non-"infinity"? 226 | pathTilesIndicator.append(pathTilesSvg, pathTilesIndicatorCount); 227 | 228 | const pauseSvg = createSvgElement('svg'); 229 | pauseSvg.setAttribute('viewBox', '0 0 16 16'); 230 | pauseSvg.setAttribute('width', 64); 231 | pauseSvg.setAttribute('height', 64); 232 | pauseSvgPath.setAttribute('fill', colors.ui); 233 | pauseSvgPath.setAttribute('stroke', colors.ui); 234 | pauseSvgPath.setAttribute('stroke-width', 2); 235 | pauseSvgPath.setAttribute('stroke-linecap', 'round'); 236 | pauseSvgPath.setAttribute('stroke-linejoin', 'round'); 237 | pauseSvgPath.setAttribute('d', 'M6 6 6 10M10 6 10 8 10 10'); 238 | pauseSvgPath.style.transition = `all .2s`; 239 | pauseSvgPath.style.transformOrigin = 'center'; 240 | pauseSvgPath.style.transform = 'rotate(180deg)'; 241 | pauseSvg.append(pauseSvgPath); 242 | 243 | pauseButton.style.cssText = `position:absolute;padding:0;pointer-events:all`; 244 | if (document.body.scrollHeight < 500) { 245 | pauseButton.style.top = '108px'; 246 | pauseButton.style.right = '20px'; 247 | } else { 248 | pauseButton.style.top = '24px'; 249 | pauseButton.style.right = '112px'; 250 | } 251 | addEventListener('resize', () => { 252 | if (document.body.scrollHeight < 500) { 253 | pauseButton.style.top = '108px'; 254 | pauseButton.style.right = '20px'; 255 | } else { 256 | pauseButton.style.top = '24px'; 257 | pauseButton.style.right = '112px'; 258 | } 259 | }); 260 | pauseButton.style.width = '64px'; 261 | pauseButton.style.height = '64px'; 262 | pauseButton.style.opacity = 0; 263 | pauseButton.append(pauseSvg); 264 | 265 | gridRedToggleSvg.setAttribute('viewBox', '0 0 16 16'); 266 | gridRedToggleSvg.setAttribute('width', 48); 267 | gridRedToggleSvg.setAttribute('height', 48); 268 | gridRedToggleSvgPath.setAttribute('fill', 'none'); 269 | gridRedToggleSvgPath.setAttribute('stroke', colors.red); 270 | gridRedToggleSvgPath.setAttribute('stroke-width', 2); 271 | gridRedToggleSvgPath.setAttribute('stroke-linecap', 'round'); 272 | gridRedToggleSvgPath.setAttribute('stroke-linejoin', 'round'); 273 | gridRedToggleSvgPath.style.transition = `all .3s`; 274 | gridRedToggleSvgPath.style.transformOrigin = 'center'; 275 | gridRedToggleSvg.append(gridRedToggleSvgPath); 276 | gridRedToggleButton.append(gridRedToggleSvg); 277 | gridRedToggleButton.style.cssText = `position:absolute;bottom:72px;right:16px;padding:0;pointer-events:all;`; 278 | gridRedToggleButton.style.width = '48px'; 279 | gridRedToggleButton.style.height = '48px'; 280 | gridRedToggleTooltip.style.cssText = ` 281 | position: absolute; 282 | display: flex; 283 | right: 16px; 284 | align-items: center; 285 | color: #eee; 286 | font-size: 16px; 287 | border-radius: 64px; 288 | padding: 0 64px 0 16px; 289 | white-space: pre; 290 | pointer-events: all; 291 | bottom: 72px; 292 | background: ${colors.ui}; 293 | `; 294 | gridRedToggleTooltip.style.height = '48px'; 295 | gridRedToggleTooltip.style.width = '96px'; 296 | gridRedToggleTooltip.style.transition = `all .5s`; 297 | 298 | gridToggleSvg.setAttribute('viewBox', '0 0 16 16'); 299 | gridToggleSvg.setAttribute('width', 48); 300 | gridToggleSvg.setAttribute('height', 48); 301 | gridToggleSvgPath.setAttribute('fill', 'none'); 302 | gridToggleSvgPath.setAttribute('stroke', colors.ui); 303 | gridToggleSvgPath.setAttribute('stroke-width', 2); 304 | gridToggleSvgPath.setAttribute('stroke-linecap', 'round'); 305 | gridToggleSvgPath.setAttribute('stroke-linejoin', 'round'); 306 | gridToggleSvgPath.style.transition = `all .3s`; 307 | gridToggleSvgPath.style.transformOrigin = 'center'; 308 | gridToggleSvg.append(gridToggleSvgPath); 309 | gridToggleButton.append(gridToggleSvg); 310 | gridToggleButton.style.cssText = `position:absolute;bottom:16px;right:16px;padding:0;pointer-events:all;`; 311 | gridToggleButton.style.width = '48px'; 312 | gridToggleButton.style.height = '48px'; 313 | gridToggleTooltip.style.cssText = ` 314 | position: absolute; 315 | display: flex; 316 | right: 16px; 317 | align-items: center; 318 | color: #eee; 319 | font-size: 16px; 320 | border-radius: 64px; 321 | padding: 0 64px 0 16px; 322 | white-space: pre; 323 | pointer-events: all; 324 | bottom: 16px; 325 | background: ${colors.ui}; 326 | `; 327 | gridToggleTooltip.style.height = '48px'; 328 | gridToggleTooltip.style.width = '96px'; 329 | gridToggleTooltip.style.transition = `all .5s`; 330 | 331 | soundToggleSvg.setAttribute('viewBox', '0 0 16 16'); 332 | soundToggleSvg.setAttribute('width', 48); 333 | soundToggleSvg.setAttribute('height', 48); 334 | soundToggleSvgPath.setAttribute('fill', 'none'); 335 | soundToggleSvgPath.setAttribute('stroke', colors.ui); 336 | soundToggleSvgPath.setAttribute('stroke-width', 2); 337 | soundToggleSvgPath.setAttribute('stroke-linecap', 'round'); 338 | soundToggleSvgPath.setAttribute('stroke-linejoin', 'round'); 339 | soundToggleSvgPath.style.transition = `all .3s`; 340 | soundToggleSvgPath.style.transformOrigin = 'center'; 341 | soundToggleSvgPath.style.transform = 'rotate(0)'; 342 | soundToggleSvgPath.setAttribute('d', 'M9 13 6 10 4 10 4 6 6 6 9 3'); 343 | soundToggleSvgPathX.setAttribute('fill', 'none'); 344 | soundToggleSvgPathX.setAttribute('stroke', colors.ui); 345 | soundToggleSvgPathX.setAttribute('stroke-width', 2); 346 | soundToggleSvgPathX.setAttribute('stroke-linecap', 'round'); 347 | soundToggleSvgPathX.setAttribute('stroke-linejoin', 'round'); 348 | soundToggleSvgPathX.style.transition = `all .3s`; 349 | soundToggleSvgPathX.style.transformOrigin = 'center'; 350 | soundToggleSvgPathX.style.transform = 'rotate(0)'; 351 | soundToggleSvg.append(soundToggleSvgPath, soundToggleSvgPathX); 352 | soundToggleButton.append(soundToggleSvg); 353 | soundToggleButton.style.cssText = `position:absolute;bottom:128px;right:16px;padding:0;pointer-events:all;`; 354 | soundToggleButton.style.width = '48px'; 355 | soundToggleButton.style.height = '48px'; 356 | soundToggleTooltip.style.cssText = ` 357 | position: absolute; 358 | display: flex; 359 | right: 16px; 360 | align-items: center; 361 | color: #eee; 362 | font-size: 16px; 363 | border-radius: 64px; 364 | padding: 0 64px 0 16px; 365 | white-space: pre; 366 | pointer-events: all; 367 | bottom: 128px; 368 | background: ${colors.ui}; 369 | `; 370 | soundToggleTooltip.style.height = '48px'; 371 | soundToggleTooltip.style.width = '96px'; 372 | soundToggleTooltip.style.transition = `all .5s`; 373 | 374 | uiContainer.append( 375 | scoreCounters, 376 | clock, 377 | pauseButton, 378 | pathTilesIndicator, 379 | gridRedToggleTooltip, 380 | gridRedToggleButton, 381 | gridToggleTooltip, 382 | gridToggleButton, 383 | soundToggleTooltip, 384 | soundToggleButton, 385 | ); 386 | }; 387 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 |