├── 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 | Screenshot of the game, showing a green background, with small dots and other simple shapes representing paths, animals, trees, and a pond. 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 |
26 | (Click to show - minor spoilers) 27 |

28 |

    29 |
  • You can build paths while the game is paused, if you need a little more time to think.
  • 30 |
  • You can delete the path that comes with the starting farm!
  • 31 |
  • Paths cannot be build over water, so to connect a fish farm you have to join a path to the end of the stepping stones.
  • 32 |
  • Distance is the most important factor when determining how well a yurt can cope with a farms demands.
  • 33 |
  • You don't have to connect every yurt!
  • 34 |
  • You can send your settlers through other farms. If the farm is of a different type, it won't interfere at all, however if it's a farm of the same type, the settlers are more likely to head there than travel through it to the further away one.
  • 35 |
  • Your settlers may get stuck at farms if they have no way home. You'll have to re-build a path for them to get back to their own yurt before they can help out again.
  • 36 |
  • Diagonal paths use fewer path tiles to go a further distance, but because they are further, it will take settlers longer to get to their destinations for the same number of grid-cells traversed.
  • 37 |
  • Farms have a "needyness" based on the animal type, times the number of animals minus 1, times a subtle difficulty-over-time curve. For example a farm with two adult oxen and one baby, will have 2 × [ox demand number] × [difficulty scaling].
  • 38 |
  • Farms issue capacity is based of the total number of adults, times 3. For example a farm with two adult oxen and one baby, will have 2 (adults) × 3 = 6 capacity, which is represented by the two starting (!) and then 4 segments in the pop-up issue indicator. This means you have to deal with farms with only two adults quickly!
  • 39 |
      40 |

      41 |
42 | 43 | ### Run locally 44 | 45 | 1. Clone this repository 46 | `git clone git@github.com:burntcustard/tiny-yurts.git` 47 | 48 | 2. Install dependencies 49 | `npm install` 50 | 51 | 3. Run dev command to start up hot-reloading with [Vite](https://vitejs.dev/) at [localhost:3000](http://localhost:3000/) (you will need to open that URL yourself!) 52 | `npm run dev` 53 | 54 | 4. Compile the output [index.html](dist/index.html) file and [game.zip]((dist/game.zip)) files (this will take a minute or two!) 55 | `npm run build` 56 | 57 | 5. See [package.json](package.json) for other scripts 58 | -------------------------------------------------------------------------------- /src/fish.js: -------------------------------------------------------------------------------- 1 | import { angleToTarget, radToDeg, Vector } from 'kontra'; 2 | import { Animal } from './animal'; 3 | // Should fish have shadows? 4 | import { animalLayer } from './layers'; 5 | import { colors } from './colors'; 6 | import { gridCellSize } from './svg'; 7 | import { createSvgElement } from './svg-utils'; 8 | import { fishCounter, fishCounterWrapper } from './ui'; 9 | 10 | // Yes the plual of fish is fish, not fishes, if it's only one kind of fish 11 | export const fishes = []; 12 | 13 | export class Fish extends Animal { 14 | constructor(properties) { 15 | super({ 16 | ...properties, 17 | parent: properties.parent, 18 | width: 0.7, 19 | height: 1, 20 | roundness: 1, 21 | color: colors.fish, 22 | }); 23 | 24 | fishes.push(this); 25 | } 26 | 27 | addToSvg() { 28 | this.scale = 0; 29 | 30 | this.svgElement = createSvgElement('g'); 31 | this.svgElement.style.transformOrigin = 'center'; 32 | this.svgElement.style.transformBox = 'fill-box'; 33 | this.svgElement.style.transition = `all 1s`; 34 | this.svgElement.style.willChange = 'transform'; 35 | animalLayer.append(this.svgElement); 36 | 37 | this.svgBody = createSvgElement('rect'); 38 | this.svgBody.setAttribute('fill', colors.fish); 39 | this.svgBody.setAttribute('width', this.width); 40 | this.svgBody.setAttribute('height', this.height); 41 | this.svgBody.setAttribute('rx', this.roundness); 42 | this.svgBody.style.transition = `fill .2s`; 43 | this.svgElement.append(this.svgBody); 44 | 45 | this.render(); 46 | 47 | fishCounterWrapper.style.width = '96px'; 48 | fishCounterWrapper.style.opacity = 1; 49 | 50 | setTimeout(() => { 51 | this.scale = 1; 52 | fishCounter.innerText = fishes.length; 53 | }, 500); 54 | 55 | setTimeout(() => { 56 | this.svgElement.style.transition = ''; 57 | this.svgElement.style.willChange = ''; 58 | }, 1500); 59 | 60 | setTimeout(() => { 61 | this.svgBody.setAttribute('fill', colors.shade2); 62 | }, 4000); 63 | } 64 | 65 | update(gameStarted) { 66 | this.advance(); 67 | 68 | if (gameStarted) { 69 | if (this.isBaby) { 70 | this.isBaby--; 71 | } 72 | } 73 | 74 | // Maybe pick a new target location 75 | if (Math.random() > 0.96) { 76 | this.target = this.getRandomTarget(); 77 | } 78 | 79 | if (this.target) { 80 | const angle = angleToTarget(this, this.target); 81 | const angleDiff = angle - this.rotation; 82 | const targetVector = Vector(this.target); 83 | const dist = targetVector.distance(this) > 1; 84 | 85 | if (Math.abs(angleDiff % (Math.PI * 2)) > 0.1) { 86 | this.rotation += angleDiff > 0 ? 0.1 : -0.1; 87 | // console.log(radToDeg(this.rotation), radToDeg(angle)); 88 | } else if (dist > 0.1) { 89 | const normalized = targetVector.subtract(this).normalize(); 90 | const newPosX = this.x + normalized.x * 0.1; 91 | const newPosY = this.y + normalized.y * 0.1; 92 | // Check if new pos is not too close to other ox 93 | const tooCloseToOtherOxes = this.parent.children.some((o) => { 94 | if (this === o) return false; 95 | const otherOxVector = Vector(o); 96 | const oldDistToOtherOx = otherOxVector.distance({ x: this.x, y: this.y }); 97 | const newDistToOtherOx = otherOxVector.distance({ x: newPosX, y: newPosY }); 98 | return newDistToOtherOx < 4 && newDistToOtherOx < oldDistToOtherOx; 99 | }); 100 | if (!tooCloseToOtherOxes) { 101 | this.x = newPosX; 102 | this.y = newPosY; 103 | } 104 | } 105 | } 106 | } 107 | 108 | render() { 109 | super.render(); 110 | 111 | const x = this.parent.x * gridCellSize + this.x - this.width / 2; 112 | const y = this.parent.y * gridCellSize + this.y - this.height / 2; 113 | 114 | this.svgElement.style.transform = ` 115 | translate(${x}px, ${y}px) 116 | rotate(${radToDeg(this.rotation) - 90}deg) 117 | scale(${this.scale * (this.isBaby ? 0.6 : 1)}) 118 | `; 119 | 120 | if (this.hasWarn) { 121 | this.svgBody.style.fill = colors.fish; 122 | } 123 | // this.svgShadowElement.style.transform = ` 124 | // translate(${x}px, ${y}px) 125 | // rotate(${radToDeg(this.rotation) - 90}deg) 126 | // scale(${(this.scale + 0.04) * (this.isBaby ? 0.6 : 1)}) 127 | // `; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/goat.js: -------------------------------------------------------------------------------- 1 | import { angleToTarget, radToDeg, Vector } from 'kontra'; 2 | import { Animal } from './animal'; 3 | import { animalLayer, animalShadowLayer } from './layers'; 4 | import { colors } from './colors'; 5 | import { gridCellSize } from './svg'; 6 | import { createSvgElement } from './svg-utils'; 7 | import { goatCounter, goatCounterWrapper } from './ui'; 8 | 9 | export const goats = []; 10 | 11 | export class Goat extends Animal { 12 | constructor(properties) { 13 | super({ 14 | ...properties, 15 | parent: properties.parent, 16 | width: 1, 17 | height: 1.5, 18 | roundness: 0.6, 19 | color: colors.goat, 20 | isBaby: properties.isBaby ? 4000 : false, 21 | }); 22 | 23 | goats.push(this); 24 | } 25 | 26 | addToSvg() { 27 | this.scale = 0; 28 | 29 | const goat = createSvgElement('g'); 30 | goat.style.transformOrigin = 'center'; 31 | goat.style.transformBox = 'fill-box'; 32 | goat.style.transition = `all 1s`; 33 | goat.style.willChange = 'transform'; 34 | this.svgElement = goat; 35 | animalLayer.prepend(goat); 36 | 37 | const body = createSvgElement('rect'); 38 | body.setAttribute('fill', colors.goat); 39 | body.setAttribute('width', this.width); 40 | body.setAttribute('height', this.height); 41 | body.setAttribute('rx', this.roundness); 42 | goat.append(body); 43 | 44 | const shadow = createSvgElement('rect'); 45 | shadow.setAttribute('width', this.width); 46 | shadow.setAttribute('height', this.height); 47 | shadow.setAttribute('rx', this.roundness); 48 | shadow.style.transformOrigin = 'center'; 49 | shadow.style.transformBox = 'fill-box'; 50 | shadow.style.transition = `all 1s`; 51 | shadow.style.willChange = 'transform'; 52 | this.svgShadowElement = shadow; 53 | animalShadowLayer.prepend(shadow); 54 | 55 | this.render(); 56 | 57 | goatCounterWrapper.style.width = '96px'; 58 | goatCounterWrapper.style.opacity = '1'; 59 | 60 | setTimeout(() => { 61 | this.scale = 1; 62 | goatCounter.innerText = goats.length; 63 | }, 500); 64 | 65 | setTimeout(() => { 66 | goat.style.transition = ''; 67 | goat.style.willChange = ''; 68 | shadow.style.willChange = ''; 69 | shadow.style.transition = ''; 70 | }, 1500); 71 | } 72 | 73 | update(gameStarted) { 74 | this.advance(); 75 | 76 | if (gameStarted) { 77 | if (this.isBaby) { 78 | this.isBaby--; 79 | } 80 | } 81 | 82 | // Maybe pick a new target location 83 | if (Math.random() > 0.96) { 84 | this.target = this.getRandomTarget(); 85 | } 86 | 87 | if (this.target) { 88 | const angle = angleToTarget(this, this.target); 89 | const angleDiff = angle - this.rotation; 90 | const targetVector = Vector(this.target); 91 | const dist = targetVector.distance(this) > 1; 92 | 93 | if (Math.abs(angleDiff % (Math.PI * 2)) > 0.1) { 94 | this.rotation += angleDiff > 0 ? 0.1 : -0.1; 95 | // console.log(radToDeg(this.rotation), radToDeg(angle)); 96 | } else if (dist > 0.1) { 97 | const normalized = targetVector.subtract(this).normalize(); 98 | const newPosX = this.x + normalized.x * 0.1; 99 | const newPosY = this.y + normalized.y * 0.1; 100 | // Check if new pos is not too close to other ox 101 | const tooCloseToOtherOxes = this.parent.children.some((o) => { 102 | if (this === o) return false; 103 | const otherOxVector = Vector(o); 104 | const oldDistToOtherOx = otherOxVector.distance({ x: this.x, y: this.y }); 105 | const newDistToOtherOx = otherOxVector.distance({ x: newPosX, y: newPosY }); 106 | return newDistToOtherOx < 4 && newDistToOtherOx < oldDistToOtherOx; 107 | }); 108 | if (!tooCloseToOtherOxes) { 109 | this.x = newPosX; 110 | this.y = newPosY; 111 | } 112 | } 113 | } 114 | } 115 | 116 | render() { 117 | super.render(); 118 | 119 | const x = this.parent.x * gridCellSize + this.x - this.width / 2; 120 | const y = this.parent.y * gridCellSize + this.y - this.height / 2; 121 | 122 | this.svgElement.style.transform = ` 123 | translate(${x}px, ${y}px) 124 | rotate(${radToDeg(this.rotation) - 90}deg) 125 | scale(${this.scale * (this.isBaby ? 0.6 : 1)}) 126 | `; 127 | this.svgShadowElement.style.transform = ` 128 | translate(${x}px, ${y}px) 129 | rotate(${radToDeg(this.rotation) - 90}deg) 130 | scale(${(this.scale + 0.04) * (this.isBaby ? 0.6 : 1)}) 131 | `; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/animal.js: -------------------------------------------------------------------------------- 1 | import { GameObjectClass } from './modified-kontra/game-object'; 2 | import { gridCellSize } from './svg'; 3 | import { createSvgElement } from './svg-utils'; 4 | import { pinLayer } from './layers'; 5 | import { playWarnNote } from './audio'; 6 | 7 | export const animals = []; 8 | const padding = 3; 9 | 10 | const getRandom = (range) => padding + (Math.random() * (range * gridCellSize - padding * 2)); 11 | 12 | export class Animal extends GameObjectClass { 13 | constructor(properties) { 14 | super({ 15 | ...properties, 16 | anchor: { x: 0.5, y: 0.5 }, 17 | x: getRandom(properties.parent?.width ?? 0), 18 | y: getRandom(properties.parent?.height ?? 0), 19 | rotation: properties.rotation ?? (Math.random() * Math.PI * 4) - Math.PI * 2, 20 | }); 21 | 22 | const x = this.parent.x * gridCellSize + this.x; 23 | const y = this.parent.y * gridCellSize + this.y; 24 | 25 | this.isBaby = properties.isBaby ?? false; 26 | this.roundness = properties.roundness; 27 | this.hasWarn = false; 28 | this.hasPerson = null; // Ref to person on their way to say hi 29 | 30 | this.pinSvg = createSvgElement('g'); 31 | this.pinSvg.style.opacity = 0; 32 | this.pinSvg.style.willChange = `opacity, transform`; 33 | this.pinSvg.style.transition = `all .8s cubic-bezier(.5, 2, .5, 1)`; 34 | this.pinSvg.style.transformOrigin = 'bottom'; 35 | this.pinSvg.style.transformBox = 'fill-box'; 36 | this.pinSvg.style.transform = `translate(${x}px, ${y - this.height / 2}px)`; 37 | pinLayer.append(this.pinSvg); 38 | 39 | const pinBubble = createSvgElement('path'); 40 | pinBubble.setAttribute('fill', '#fff'); 41 | pinBubble.setAttribute('d', 'm6 6-2-2a3 3 0 1 1 4 0Z'); 42 | pinBubble.setAttribute('transform', 'scale(.5) translate(-6 -8)'); 43 | this.pinSvg.append(pinBubble); 44 | 45 | // ! 46 | this.warnSvg = createSvgElement('path'); 47 | this.warnSvg.setAttribute('stroke', this.color); 48 | this.warnSvg.setAttribute('d', 'M3 6 3 6M3 4.5 3 3'); 49 | this.warnSvg.setAttribute('transform', 'scale(.5) translate(-3 -10.4)'); 50 | this.warnSvg.style.opacity = 0; 51 | this.pinSvg.append(this.warnSvg); 52 | 53 | // ♥ 54 | this.loveSvg = createSvgElement('path'); 55 | this.loveSvg.setAttribute('fill', this.color); 56 | this.loveSvg.setAttribute('d', 'M6 6 4 4A1 1 0 1 1 6 2 1 1 0 1 1 8 4Z'); 57 | this.loveSvg.setAttribute('transform', 'scale(.3) translate(-6 -13)'); 58 | this.loveSvg.style.opacity = 0; 59 | this.pinSvg.append(this.loveSvg); 60 | 61 | animals.push(this); 62 | } 63 | 64 | render() { 65 | const x = this.parent.x * gridCellSize + this.x; 66 | const y = this.parent.y * gridCellSize + this.y; 67 | 68 | this.pinSvg.style.transform = ` 69 | translate(${x}px, ${y - this.height / 2}px) 70 | scale(${this.hasWarn || this.hasLove ? 1 : 0}) 71 | `; 72 | 73 | // this.testSvg.style.transform = ` 74 | // translate(${x}px, ${y}px) 75 | // scale(${0.5}) 76 | // `; 77 | } 78 | 79 | getRandomTarget() { 80 | const randomTarget = { 81 | x: getRandom(this.parent.width), 82 | y: getRandom(this.parent.height), 83 | }; 84 | 85 | // const debug = createSvgElement('circle'); 86 | // const x = this.parent.x * gridCellSize + randomTarget.x; 87 | // const y = this.parent.y * gridCellSize + randomTarget.y; 88 | // debug.setAttribute('transform', `translate(${x},${y})`); 89 | // debug.setAttribute('r', .5); 90 | // debug.setAttribute('fill', 'red'); 91 | // pointerLayer.append(debug); 92 | 93 | return randomTarget; 94 | } 95 | 96 | showLove() { 97 | this.hasLove = true; 98 | this.pinSvg.style.opacity = 1; 99 | this.warnSvg.style.opacity = 0; 100 | this.loveSvg.style.opacity = 1; 101 | } 102 | 103 | hideLove() { 104 | this.hasLove = false; 105 | this.pinSvg.style.opacity = this.hasWarn ? 1 : 0; 106 | this.warnSvg.style.opacity = this.hasWarn ? 1 : 0; 107 | this.loveSvg.style.opacity = 0; 108 | } 109 | 110 | showWarn() { 111 | playWarnNote(this.color); 112 | this.hasWarn = true; 113 | this.warnSvg.style.opacity = 1; 114 | this.loveSvg.style.opacity = 0; 115 | this.pinSvg.style.opacity = 1; 116 | } 117 | 118 | hideWarn() { 119 | this.hasWarn = false; 120 | this.loveSvg.style.opacity = this.hasLove ? 1 : 0; 121 | this.pinSvg.style.opacity = this.hasLove ? 1 : 0; 122 | this.warnSvg.style.opacity = 0; 123 | } 124 | 125 | toggleWarn(toggle) { 126 | if (toggle) { 127 | this.showWarn(); 128 | } else { 129 | this.hideWarn(); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/ox.js: -------------------------------------------------------------------------------- 1 | import { angleToTarget, radToDeg, Vector } from 'kontra'; 2 | import { Animal } from './animal'; 3 | import { animalLayer, animalShadowLayer } from './layers'; 4 | import { colors } from './colors'; 5 | import { gridCellSize } from './svg'; 6 | import { createSvgElement } from './svg-utils'; 7 | import { oxCounter, oxCounterWrapper } from './ui'; 8 | 9 | export const oxen = []; 10 | 11 | export class Ox extends Animal { 12 | constructor(properties) { 13 | super({ 14 | ...properties, 15 | parent: properties.parent, 16 | width: 1.5, 17 | height: 2.5, 18 | roundness: 0.6, 19 | color: colors.ox, 20 | isBaby: properties.isBaby ? 5000 : false, 21 | }); 22 | 23 | oxen.push(this); 24 | } 25 | 26 | addToSvg() { 27 | this.scale = 0; 28 | 29 | const ox = createSvgElement('g'); 30 | ox.style.transformOrigin = 'center'; 31 | ox.style.transformBox = 'fill-box'; 32 | ox.style.transition = `all 1s`; 33 | ox.style.willChange = 'transform'; 34 | this.svgElement = ox; 35 | animalLayer.prepend(ox); 36 | 37 | const body = createSvgElement('rect'); 38 | body.setAttribute('fill', colors.ox); 39 | body.setAttribute('width', this.width); 40 | body.setAttribute('height', this.height); 41 | body.setAttribute('rx', this.roundness); 42 | ox.append(body); 43 | 44 | const horns = createSvgElement('path'); 45 | horns.setAttribute('fill', 'none'); 46 | horns.setAttribute('stroke', colors.oxHorn); 47 | horns.setAttribute('width', this.width); 48 | horns.setAttribute('height', this.height); 49 | horns.setAttribute('d', 'M0 2Q0 1 1 1Q2 1 2 2'); 50 | horns.setAttribute('transform', 'translate(-0.2 .6)'); 51 | horns.setAttribute('stroke-width', 0.4); 52 | if (this.isBaby) { 53 | horns.style.transition = `all 1s`; 54 | horns.style.willChange = 'opacity'; 55 | horns.style.opacity = 0; 56 | } 57 | this.svgHorns = horns; 58 | ox.append(horns); 59 | 60 | const shadow = createSvgElement('rect'); 61 | shadow.setAttribute('width', this.width); 62 | shadow.setAttribute('height', this.height); 63 | shadow.setAttribute('rx', this.roundness); 64 | shadow.style.transformOrigin = 'center'; 65 | shadow.style.transformBox = 'fill-box'; 66 | shadow.style.transition = `all 1s`; 67 | shadow.style.willChange = 'transform'; 68 | this.svgShadowElement = shadow; 69 | animalShadowLayer.prepend(shadow); 70 | 71 | this.render(); 72 | 73 | oxCounterWrapper.style.width = '96px'; 74 | oxCounterWrapper.style.opacity = '1'; 75 | 76 | setTimeout(() => { 77 | this.scale = 1; 78 | 79 | // Only add to the counter after 1/2 a second, otherwise it ruins the surprise! 80 | oxCounter.innerText = oxen.length; 81 | }, 500); 82 | 83 | setTimeout(() => { 84 | ox.style.transition = ''; 85 | ox.style.willChange = ''; 86 | shadow.style.willChange = ''; 87 | shadow.style.transition = ''; 88 | }, 1500); 89 | } 90 | 91 | update(gameStarted) { 92 | this.advance(); 93 | 94 | if (gameStarted) { 95 | if (this.isBaby === 1) { 96 | this.svgHorns.style.opacity = 1; 97 | } 98 | 99 | if (this.isBaby) { 100 | this.isBaby--; 101 | } 102 | } 103 | 104 | // Maybe pick a new target location 105 | if (Math.random() > 0.99) { 106 | this.target = this.getRandomTarget(); 107 | } 108 | 109 | if (this.target) { 110 | const angle = angleToTarget(this, this.target); 111 | const angleDiff = angle - this.rotation; 112 | const targetVector = Vector(this.target); 113 | const dist = targetVector.distance(this) > 1; 114 | 115 | if (Math.abs(angleDiff % (Math.PI * 2)) > 0.1) { 116 | this.rotation += angleDiff > 0 ? 0.04 : -0.04; 117 | // console.log(radToDeg(this.rotation), radToDeg(angle)); 118 | } else if (dist > 0.1) { 119 | const normalized = targetVector.subtract(this).normalize(); 120 | const newPosX = this.x + normalized.x * 0.05; 121 | const newPosY = this.y + normalized.y * 0.05; 122 | // Check if new pos is not too close to other ox 123 | const tooCloseToOtherOxes = this.parent.children.some((o) => { 124 | if (this === o) return false; 125 | const otherOxVector = Vector(o); 126 | const oldDistToOtherOx = otherOxVector.distance({ x: this.x, y: this.y }); 127 | const newDistToOtherOx = otherOxVector.distance({ x: newPosX, y: newPosY }); 128 | return newDistToOtherOx < 4 && newDistToOtherOx < oldDistToOtherOx; 129 | }); 130 | if (!tooCloseToOtherOxes) { 131 | this.x = newPosX; 132 | this.y = newPosY; 133 | } 134 | } 135 | } 136 | } 137 | 138 | render() { 139 | // super.render() also re-renders children in their new locations. 140 | // For example the little warning speech bubble things 141 | super.render(); 142 | 143 | const x = this.parent.x * gridCellSize + this.x - this.width / 2; 144 | const y = this.parent.y * gridCellSize + this.y - this.height / 2; 145 | 146 | this.svgElement.style.transform = ` 147 | translate(${x}px, ${y}px) 148 | rotate(${radToDeg(this.rotation) - 90}deg) 149 | scale(${this.scale * (this.isBaby ? 0.5 : 1)}) 150 | `; 151 | this.svgShadowElement.style.transform = ` 152 | translate(${x}px, ${y}px) 153 | rotate(${radToDeg(this.rotation) - 90}deg) 154 | scale(${(this.scale + 0.04) * (this.isBaby ? 0.5 : 1)}) 155 | `; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/menu.js: -------------------------------------------------------------------------------- 1 | import { 2 | svgElement, boardOffsetX, boardOffsetY, gridWidth, gridHeight, 3 | } from './svg'; 4 | import { svgPxToDisplayPx } from './cell'; 5 | import { menuBackground } from './menu-background'; 6 | import { createElement } from './create-element'; 7 | import { 8 | gridToggleTooltip, gridRedToggleTooltip, soundToggleTooltip, uiContainer, 9 | } from './ui'; 10 | import { initAudio } from './audio'; 11 | 12 | const menuWrapper = createElement(); 13 | const menuHeader = createElement(); 14 | export const menuText1 = createElement(); 15 | const menuButtons = createElement(); 16 | const startButtonWrapper = createElement(); 17 | const startButton = createElement('button'); 18 | const fullscreenButtonWrapper = createElement(); 19 | const fullscreenButton = createElement('button'); 20 | 21 | export const initMenu = (startGame) => { 22 | menuWrapper.style.cssText = ` 23 | position: absolute; 24 | inset: 0; 25 | padding: 10vmin; 26 | display: flex; 27 | flex-direction: column; 28 | `; 29 | menuWrapper.style.pointerEvents = 'none'; 30 | 31 | // This has to be a sibling element, behind the gameoverScreen, not a child of it, 32 | // so that the backdrop-filter can transition properly 33 | menuBackground.style.clipPath = 'polygon(0 0, calc(20dvw + 400px) 0, calc(20dvw + 350px) 100%, 0 100%)'; 34 | 35 | menuHeader.style.cssText = `font-size: 72px; opacity: 0;`; 36 | menuHeader.innerText = 'Tiny Yurts'; 37 | 38 | // Everything but bottom margin 39 | menuText1.style.cssText = `margin: auto 4px 0; opacity:0;`; 40 | 41 | if (localStorage.getItem('Tiny Yurts')) { 42 | menuText1.innerText = `Highscore: ${localStorage.getItem('Tiny Yurts')}`; 43 | } 44 | 45 | startButton.innerText = 'Start'; 46 | startButton.addEventListener('click', () => { 47 | initAudio(); 48 | startGame(); 49 | }); 50 | startButtonWrapper.style.opacity = 0; 51 | 52 | fullscreenButton.innerText = 'Fullscreen'; 53 | fullscreenButton.addEventListener('click', () => { 54 | initAudio(); 55 | 56 | if (document.fullscreenElement) { 57 | document.exitFullscreen(); 58 | } else { 59 | document.documentElement.requestFullscreen(); 60 | // console.log(screen.orientation.lock()); 61 | // The catch prevents an error on browsers that do not support or do not want 62 | // to allow locking to landscape. Could remove, but having an error is risky 63 | screen.orientation.lock('landscape').catch(() => {}); 64 | } 65 | }); 66 | fullscreenButtonWrapper.style.opacity = 0; 67 | 68 | menuButtons.style.cssText = `display: grid; gap: 16px; margin-top: 48px;`; 69 | startButtonWrapper.append(startButton); 70 | fullscreenButtonWrapper.append(fullscreenButton); 71 | 72 | menuButtons.append(fullscreenButtonWrapper, startButtonWrapper); 73 | 74 | menuWrapper.append(menuHeader, menuButtons, menuText1); 75 | 76 | document.body.append(menuWrapper); 77 | }; 78 | 79 | export const showMenu = (focus, firstTime) => { 80 | menuWrapper.style.pointerEvents = ''; 81 | menuBackground.style.clipPath = `polygon(0 0, calc(20dvw + 400px) 0, calc(20dvw + 350px) 100%, 0 100%)`; 82 | menuBackground.style.transition = `clip-path 1s, opacity 2s`; 83 | menuHeader.style.transition = `opacity .5s 1s`; 84 | fullscreenButtonWrapper.style.transition = `opacity .5s 1.2s`; 85 | startButtonWrapper.style.transition = `opacity .5s 1.4s`; 86 | menuText1.style.transition = `opacity .5s 1.6s`; 87 | 88 | // First time the game is loaded, the menu background needs to be fast 89 | if (firstTime) { 90 | menuBackground.style.transition = `opacity 0s`; 91 | menuHeader.style.transition = `opacity .5s .4s`; 92 | fullscreenButtonWrapper.style.transition = `opacity .5s .6s`; 93 | startButtonWrapper.style.transition = `opacity .5s .8s`; 94 | menuText1.style.transition = `opacity .5s 1s`; 95 | } 96 | 97 | menuText1.innerHTML = localStorage.getItem('Tiny Yurts') 98 | ? `Highscore: ${localStorage.getItem('Tiny Yurts')}` 99 | : 'Tip: Left click & drag to connect yurts to
farms, or delete paths with right click.'; 100 | 101 | const farmPxPosition = svgPxToDisplayPx( 102 | focus.x - gridWidth / 2 - boardOffsetX + focus.width / 2, 103 | focus.y - gridHeight / 2 - boardOffsetY + focus.height / 2, 104 | ); 105 | const xOffset = innerWidth / 4; // TODO: Calculate properly? 106 | svgElement.style.transition = ''; 107 | svgElement.style.transform = `translate(${xOffset}px, 0) rotate(-17deg) scale(2) translate(${-farmPxPosition.x}px, ${-farmPxPosition.y}px)`; 108 | 109 | uiContainer.style.zIndex = 1; 110 | menuBackground.style.opacity = 1; 111 | menuHeader.style.opacity = 1; 112 | menuText1.style.opacity = 1; 113 | startButtonWrapper.style.opacity = 1; 114 | fullscreenButtonWrapper.style.opacity = 1; 115 | }; 116 | 117 | export const hideMenu = () => { 118 | menuWrapper.style.pointerEvents = 'none'; 119 | uiContainer.style.zIndex = ''; 120 | 121 | menuBackground.style.transition = `opacity 1s .6s`; 122 | menuHeader.style.transition = `opacity .3s .4s`; 123 | fullscreenButtonWrapper.style.transition = `opacity .3s .3s`; 124 | startButtonWrapper.style.transition = `opacity .3s .2s`; 125 | menuText1.style.transition = `opacity.3s.1s`; 126 | 127 | menuBackground.style.opacity = 0; 128 | fullscreenButtonWrapper.style.opacity = 0; 129 | startButtonWrapper.style.opacity = 0; 130 | fullscreenButtonWrapper.style.transition = 0; 131 | menuText1.style.opacity = 0; 132 | menuHeader.style.opacity = 0; 133 | 134 | soundToggleTooltip.style.opacity = 0; 135 | gridRedToggleTooltip.style.opacity = 0; 136 | gridToggleTooltip.style.opacity = 0; 137 | soundToggleTooltip.style.width = 0; 138 | gridRedToggleTooltip.style.width = 0; 139 | gridToggleTooltip.style.width = 0; 140 | }; 141 | -------------------------------------------------------------------------------- /src/yurt.js: -------------------------------------------------------------------------------- 1 | import { GameObjectClass } from './modified-kontra/game-object'; 2 | import { createSvgElement } from './svg-utils'; 3 | import { gridCellSize } from './svg'; 4 | import { 5 | baseLayer, yurtLayer, yurtAndPersonShadowLayer, 6 | } from './layers'; 7 | import { Path, drawPaths, getPathsData } from './path'; 8 | import { colors } from './colors'; 9 | import { Person } from './person'; 10 | import { playYurtSpawnNote } from './audio'; 11 | 12 | export const yurts = []; 13 | 14 | /** 15 | * Yurts each need to have... 16 | * - types - Ox is brown, goat is grey, etc. 17 | * - number of people currently inside? 18 | * - people belonging to the yurt? 19 | * - x and y coordinate in the grid 20 | */ 21 | 22 | export class Yurt extends GameObjectClass { 23 | constructor(properties) { 24 | const { x, y } = properties; 25 | 26 | super(properties); 27 | 28 | this.points = [{ 29 | x: this.x, 30 | y: this.y, 31 | }]; 32 | 33 | setTimeout(() => { 34 | this.startPath = new Path({ 35 | points: [ 36 | { x, y, fixed: true }, 37 | { x: x + this.facing.x, y: y + this.facing.y }, 38 | ], 39 | }); 40 | 41 | drawPaths({ 42 | changedCells: [ 43 | { x, y, fixed: true }, 44 | { x: x + this.facing.x, y: y + this.facing.y }, 45 | ], 46 | noShadow: true, 47 | }); 48 | }, 1000); 49 | 50 | setTimeout(() => { 51 | this.children.push(new Person({ x: this.x, y: this.y, parent: this })); 52 | this.children.push(new Person({ x: this.x, y: this.y, parent: this })); 53 | this.children.forEach((p) => p.addToSvg()); 54 | }, 2000); 55 | 56 | setTimeout(() => { 57 | playYurtSpawnNote(); 58 | }, 100); 59 | 60 | yurts.push(this); 61 | this.addToSvg(); 62 | } 63 | 64 | rotateTo(x, y) { 65 | this.facing = { 66 | x: x - this.x, 67 | y: y - this.y, 68 | }; 69 | 70 | const oldPathsInPathData = getPathsData().filter((p) => p.path === this.startPath 71 | || p.path1 === this.startPath 72 | || p.path2 === this.startPath); 73 | 74 | oldPathsInPathData.forEach((p) => { 75 | p.svgElement.setAttribute('stroke-width', 0); 76 | p.svgElement.setAttribute('opacity', 0); 77 | 78 | setTimeout(() => { 79 | p.svgElement.remove(); 80 | }, 500); 81 | }); 82 | 83 | // this.startPath.points[1] = { x: this.x, y: this.y }; 84 | // console.log(this.startPath.points[1]); 85 | if (this.startPath) { 86 | this.oldStartPath = this.startPath; 87 | this.oldStartPath.noConnect = true; 88 | } 89 | 90 | // Add the new path 91 | this.startPath = new Path({ 92 | points: [ 93 | { x: this.x, y: this.y, fixed: true }, 94 | { x, y }, 95 | ], 96 | }); 97 | 98 | // Redraw 99 | drawPaths({ changedCells: [{ x: this.x, y: this.y, fixed: true }, { x, y }], fadeout: true }); 100 | 101 | // I think this slowed down the drawing of the path slightly but seems not needed 102 | // const pathInPathData = pathsData.find(p => p.path === this.startPath); 103 | // if (pathInPathData) { 104 | // pathInPathData.svgElement.setAttribute('stroke-width', 0); 105 | // setTimeout(() => { 106 | // pathInPathData.svgElement.removeAttribute('stroke-width'); 107 | // }, 100); 108 | // } 109 | 110 | setTimeout(() => { 111 | // TODO: Figure it out if ? is necessary. Not having it it caused a crash once 112 | this.oldStartPath?.remove(); 113 | }, 400); 114 | } 115 | 116 | addToSvg() { 117 | const x = gridCellSize / 2 + this.x * gridCellSize; 118 | const y = gridCellSize / 2 + this.y * gridCellSize; 119 | 120 | const baseShadow = createSvgElement('circle'); 121 | baseShadow.setAttribute('fill', colors.shade); 122 | baseShadow.setAttribute('r', 0); 123 | baseShadow.setAttribute('stroke', 'none'); 124 | baseShadow.setAttribute('transform', `translate(${x},${y})`); 125 | baseShadow.style.willChange = `r, opacity`; 126 | baseShadow.style.opacity = 0; 127 | baseShadow.style.transition = `all .4s`; 128 | baseLayer.append(baseShadow); 129 | setTimeout(() => { 130 | baseShadow.setAttribute('r', 3); 131 | baseShadow.style.opacity = 1; 132 | }, 100); 133 | setTimeout(() => baseShadow.style.willChange = '', 600); 134 | 135 | this.svgGroup = createSvgElement('g'); 136 | this.svgGroup.style.transform = `translate(${x}px,${y}px)`; 137 | yurtLayer.append(this.svgGroup); 138 | 139 | this.circle = createSvgElement('circle'); 140 | this.circle.style.transition = 'r.4s'; 141 | this.circle.style.willChange = 'r'; 142 | setTimeout(() => this.circle.setAttribute('r', 3), 400); 143 | setTimeout(() => this.circle.style.willChange = '', 900); 144 | 145 | this.shadow = createSvgElement('path'); 146 | this.shadow.setAttribute('d', 'M0 0 0 0'); 147 | this.shadow.setAttribute('stroke-width', 6); 148 | this.shadow.style.transform = `translate(${x}px,${y}px)`; 149 | this.shadow.style.opacity = 0; 150 | this.shadow.style.willChange = 'd'; 151 | this.shadow.style.transition = 'd.6s'; 152 | yurtAndPersonShadowLayer.append(this.shadow); 153 | setTimeout(() => this.shadow.style.opacity = 0.8, 800); 154 | setTimeout(() => this.shadow.setAttribute('d', 'M0 0 2 2'), 900); 155 | setTimeout(() => this.shadow.style.willChange = '', 1600); 156 | 157 | this.decoration = createSvgElement('circle'); 158 | this.decoration.setAttribute('fill', 'none'); 159 | this.decoration.setAttribute('r', 1); 160 | this.decoration.setAttribute('stroke-dasharray', 6.3); // Math.PI * 2 + a bit 161 | this.decoration.setAttribute('stroke-dashoffset', 6.3); 162 | this.decoration.setAttribute('stroke', this.type); 163 | this.decoration.style.willChange = 'stroke-dashoffset'; 164 | this.decoration.style.transition = `stroke-dashoffset .5s`; 165 | 166 | this.svgGroup.append(this.circle, this.decoration); 167 | 168 | setTimeout(() => this.decoration.setAttribute('stroke-dashoffset', 0), 700); 169 | setTimeout(() => this.decoration.style.willChange = '', 1300); 170 | } 171 | 172 | lift() { 173 | const x = gridCellSize / 2 + this.x * gridCellSize; 174 | const y = gridCellSize / 2 + this.y * gridCellSize; 175 | 176 | this.shadow.style.transition = 'transform.2s d.3s'; 177 | this.shadow.setAttribute('d', 'M0 0l3 3'); 178 | 179 | this.svgGroup.style.transition = 'transform.2s'; 180 | this.svgGroup.style.transform = `translate(${x}px,${y}px) scale(1.1)`; 181 | } 182 | 183 | place() { 184 | const x = gridCellSize / 2 + this.x * gridCellSize; 185 | const y = gridCellSize / 2 + this.y * gridCellSize; 186 | 187 | this.shadow.style.transition = 'transform.3s d.4s'; 188 | this.shadow.setAttribute('d', 'M0 0 2 2'); 189 | this.shadow.style.transform = `translate(${x}px,${y}px) scale(1)`; 190 | 191 | this.svgGroup.style.transition = 'transform.3s'; 192 | this.svgGroup.style.transform = `translate(${x}px,${y}px) scale(1)`; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/layers.js: -------------------------------------------------------------------------------- 1 | import { addGridBackgroundToSvg, addGridToSvg, gridLineThickness } from './grid'; 2 | import { 3 | svgElement, boardOffsetX, boardOffsetY, boardSvgWidth, boardSvgHeight, gridCellSize, 4 | } from './svg'; 5 | import { createSvgElement } from './svg-utils'; 6 | import { colors, shadowOpacity } from './colors'; 7 | 8 | const addAnimalShadowLayer = () => { 9 | const animalShadowLayer = createSvgElement('g'); 10 | animalShadowLayer.setAttribute('opacity', shadowOpacity); 11 | animalShadowLayer.setAttribute('transform', 'translate(.3,.3)'); 12 | svgElement.append(animalShadowLayer); 13 | return animalShadowLayer; 14 | }; 15 | 16 | const addAnimalLayer = () => { 17 | const animalLayer = createSvgElement('g'); 18 | animalLayer.setAttribute('stroke-linecap', 'round'); 19 | svgElement.append(animalLayer); 20 | return animalLayer; 21 | }; 22 | 23 | const addFenceShadowLayer = () => { 24 | const fenceShadowLayer = createSvgElement('g'); 25 | fenceShadowLayer.setAttribute('stroke-linecap', 'round'); 26 | fenceShadowLayer.setAttribute('fill', 'none'); 27 | fenceShadowLayer.setAttribute('stroke', colors.black); 28 | fenceShadowLayer.setAttribute('opacity', shadowOpacity); 29 | fenceShadowLayer.setAttribute('transform', 'translate(.5,.5)'); 30 | svgElement.append(fenceShadowLayer); 31 | return fenceShadowLayer; 32 | }; 33 | 34 | const addRockShadowLayer = () => { 35 | const rockShadowLayer = createSvgElement('g'); 36 | rockShadowLayer.setAttribute('stroke-linecap', 'round'); 37 | rockShadowLayer.setAttribute('fill', 'none'); 38 | rockShadowLayer.setAttribute('stroke', colors.black); 39 | rockShadowLayer.setAttribute('opacity', shadowOpacity); 40 | rockShadowLayer.setAttribute('transform', 'translate(.3,.3)'); 41 | svgElement.append(rockShadowLayer); 42 | return rockShadowLayer; 43 | }; 44 | 45 | const addGridBlockLayer = () => { 46 | const gridBlockLayer = createSvgElement('g'); 47 | gridBlockLayer.setAttribute('fill', 'none'); 48 | svgElement.append(gridBlockLayer); 49 | return gridBlockLayer; 50 | }; 51 | 52 | const addFenceLayer = () => { 53 | const fenceLayer = createSvgElement('g'); 54 | fenceLayer.setAttribute('stroke-linecap', 'round'); 55 | fenceLayer.setAttribute('fill', 'none'); 56 | svgElement.append(fenceLayer); 57 | return fenceLayer; 58 | }; 59 | 60 | const addBaseLayer = () => { 61 | const baseLayer = createSvgElement('g'); 62 | baseLayer.setAttribute('fill', colors.base); 63 | svgElement.append(baseLayer); 64 | return baseLayer; 65 | }; 66 | 67 | const addPathShadowLayer = () => { 68 | const pathShadowLayer = createSvgElement('g'); 69 | pathShadowLayer.setAttribute('stroke-linecap', 'round'); 70 | pathShadowLayer.setAttribute('fill', 'none'); 71 | pathShadowLayer.setAttribute('stroke', colors.base); 72 | pathShadowLayer.setAttribute('stroke-width', 3.14); 73 | svgElement.append(pathShadowLayer); 74 | return pathShadowLayer; 75 | }; 76 | 77 | const addPathLayer = () => { 78 | const pathLayer = createSvgElement('g'); 79 | pathLayer.setAttribute('stroke-linecap', 'round'); 80 | pathLayer.setAttribute('fill', 'none'); 81 | pathLayer.setAttribute('stroke', colors.path); 82 | pathLayer.setAttribute('stroke-width', 3.14); 83 | svgElement.append(pathLayer); 84 | return pathLayer; 85 | }; 86 | 87 | const addPersonLayer = () => { 88 | const personLayer = createSvgElement('g'); 89 | personLayer.setAttribute('stroke-linecap', 'round'); 90 | personLayer.setAttribute('fill', 'none'); 91 | svgElement.append(personLayer); 92 | return personLayer; 93 | }; 94 | 95 | const addPondLayer = () => { 96 | const pondLayer = createSvgElement('g'); 97 | svgElement.append(pondLayer); 98 | return pondLayer; 99 | }; 100 | 101 | const addYurtAndPersonShadowLayer = () => { 102 | const shadowLayer = createSvgElement('g'); 103 | shadowLayer.setAttribute('stroke-linecap', 'round'); 104 | shadowLayer.setAttribute('fill', 'none'); 105 | shadowLayer.setAttribute('stroke', colors.black); 106 | shadowLayer.setAttribute('opacity', 0.2); 107 | svgElement.append(shadowLayer); 108 | return shadowLayer; 109 | }; 110 | 111 | const addYurtLayer = () => { 112 | const yurtLayer = createSvgElement('g'); 113 | yurtLayer.setAttribute('stroke-linecap', 'round'); 114 | yurtLayer.setAttribute('fill', colors.yurt); 115 | svgElement.append(yurtLayer); 116 | return yurtLayer; 117 | }; 118 | 119 | const addTreeShadowLayer = () => { 120 | const treeShadowLayer = createSvgElement('g'); 121 | svgElement.append(treeShadowLayer); 122 | return treeShadowLayer; 123 | }; 124 | 125 | const addTreeLayer = () => { 126 | const treeLayer = createSvgElement('g'); 127 | svgElement.append(treeLayer); 128 | return treeLayer; 129 | }; 130 | 131 | const addPinLayer = () => { 132 | const pinLayer = createSvgElement('g'); 133 | pinLayer.setAttribute('stroke-linecap', 'round'); 134 | svgElement.append(pinLayer); 135 | return pinLayer; 136 | }; 137 | 138 | const addGridPointerLayer = () => { 139 | const gridPointerLayer = createSvgElement('rect'); 140 | gridPointerLayer.setAttribute('width', `${boardSvgWidth + gridLineThickness}px`); 141 | gridPointerLayer.setAttribute('height', `${boardSvgHeight + gridLineThickness}px`); 142 | gridPointerLayer.setAttribute('transform', `translate(${boardOffsetX * gridCellSize - gridLineThickness} ${boardOffsetY * gridCellSize - gridLineThickness})`); 143 | gridPointerLayer.setAttribute('fill', 'none'); 144 | gridPointerLayer.setAttribute('stroke-width', 0); 145 | gridPointerLayer.style.cursor = 'cell'; 146 | gridPointerLayer.style.pointerEvents = 'all'; 147 | svgElement.append(gridPointerLayer); 148 | return gridPointerLayer; 149 | }; 150 | 151 | // Order is important here, because it determines stacking in the SVG 152 | const layers = { 153 | gridBackgroundLayer: addGridBackgroundToSvg(), 154 | pondLayer: addPondLayer(), 155 | gridLayer: addGridToSvg(), 156 | gridBlockLayer: addGridBlockLayer(), 157 | baseLayer: addBaseLayer(), 158 | pathShadowLayer: addPathShadowLayer(), 159 | rockShadowLayer: addRockShadowLayer(), 160 | pathLayer: addPathLayer(), 161 | animalShadowLayer: addAnimalShadowLayer(), 162 | yurtAndPersonShadowLayer: addYurtAndPersonShadowLayer(), 163 | animalLayer: addAnimalLayer(), 164 | personLayer: addPersonLayer(), 165 | fenceShadowLayer: addFenceShadowLayer(), 166 | fenceLayer: addFenceLayer(), 167 | treeShadowLayer: addTreeShadowLayer(), 168 | yurtLayer: addYurtLayer(), 169 | treeLayer: addTreeLayer(), 170 | pinLayer: addPinLayer(), 171 | gridPointerLayer: addGridPointerLayer(), 172 | }; 173 | 174 | export const { 175 | animalLayer, 176 | animalShadowLayer, 177 | baseLayer, 178 | fenceLayer, 179 | fenceShadowLayer, 180 | gridBlockLayer, 181 | gridLayer, 182 | gridPointerLayer, 183 | pathLayer, 184 | pathShadowLayer, 185 | personLayer, 186 | pinLayer, 187 | pondLayer, 188 | rockShadowLayer, 189 | treeLayer, 190 | treeShadowLayer, 191 | yurtAndPersonShadowLayer, 192 | yurtLayer, 193 | } = layers; 194 | 195 | export const clearLayers = () => { 196 | animalLayer.innerHTML = ''; 197 | animalShadowLayer.innerHTML = ''; 198 | baseLayer.innerHTML = ''; 199 | fenceLayer.innerHTML = ''; 200 | fenceShadowLayer.innerHTML = ''; 201 | gridBlockLayer.innerHTML = ''; 202 | pathLayer.innerHTML = ''; 203 | pathShadowLayer.innerHTML = ''; 204 | personLayer.innerHTML = ''; 205 | pinLayer.innerHTML = ''; 206 | pondLayer.innerHTML = ''; 207 | rockShadowLayer.innerHTML = ''; 208 | treeLayer.innerHTML = ''; 209 | treeShadowLayer.innerHTML = ''; 210 | yurtAndPersonShadowLayer.innerHTML = ''; 211 | yurtLayer.innerHTML = ''; 212 | }; 213 | -------------------------------------------------------------------------------- /plugins/vite-js13k.js: -------------------------------------------------------------------------------- 1 | import { Packer } from 'roadroller'; 2 | import htmlMinifier from 'html-minifier'; 3 | import JSZip from 'jszip'; 4 | import fs from 'fs'; 5 | import advzip from 'advzip-bin'; 6 | import { execFile } from 'child_process'; 7 | 8 | async function zip(content) { 9 | const jszip = new JSZip(); 10 | 11 | jszip.file( 12 | 'index.html', 13 | content, 14 | { 15 | compression: 'DEFLATE', 16 | compressionOptions: { 17 | level: 9, 18 | }, 19 | }, 20 | ); 21 | 22 | await new Promise((resolve) => { 23 | jszip.generateNodeStream({ type: 'nodebuffer', streamFiles: true }) 24 | .pipe(fs.createWriteStream('dist/game.zip')) 25 | .on('finish', () => { 26 | resolve(); 27 | }); 28 | }); 29 | } 30 | 31 | export async function replaceScript(html, scriptFilename, scriptCode) { 32 | const reScript = new RegExp(`]*?) src="[./]*${scriptFilename}"([^>]*)>`); 33 | 34 | // First we have to move the script to the end of the body, because vite is 35 | // opinionated and otherwise just hoists it into : 36 | // https://github.com/vitejs/vite/issues/7838 37 | const _html = html 38 | .replace('', html.match(reScript)[0] + '${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 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { GameLoop } from './modified-kontra/game-loop'; 2 | import { 3 | svgElement, gridWidth, gridHeight, boardOffsetX, boardOffsetY, 4 | } from './svg'; 5 | import { initPointer } from './pointer'; 6 | import { oxFarms } from './ox-farm'; 7 | import { goatFarms } from './goat-farm'; 8 | import { fishFarms } from './fish-farm'; 9 | import { people } from './person'; 10 | import { inventory } from './inventory'; 11 | // We import a huge amount from UI, & should probably use more of it inside itself rather than here 12 | import { 13 | // eslint-disable-next-line max-len 14 | initUi, scoreCounters, goatCounter, goatCounterWrapper, oxCounter, oxCounterWrapper, fishCounter, fishCounterWrapper, pathTilesIndicator, pathTilesIndicatorCount, clock, clockHand, clockMonth, pauseButton, pauseSvgPath, gridToggleButton, gridRedToggleButton, soundToggleButton, soundToggleTooltip, soundToggleSvgPathX, gridRedToggleTooltip, gridToggleTooltip, 15 | } from './ui'; 16 | import { farms } from './farm'; 17 | import { svgPxToDisplayPx } from './cell'; 18 | import { spawnNewObjects } from './spawning'; 19 | import { animals } from './animal'; 20 | import { oxen } from './ox'; 21 | import { goats } from './goat'; 22 | import { fishes } from './fish'; 23 | import { ponds } from './pond'; 24 | import { yurts } from './yurt'; 25 | import { paths } from './path'; 26 | import { clearLayers } from './layers'; 27 | import { initMenuBackground } from './menu-background'; 28 | import { 29 | initGameover, showGameover, hideGameover, toggleGameoverlayButton, 30 | } from './gameover'; 31 | import { initMenu, showMenu, hideMenu } from './menu'; 32 | import { updateGridData } from './find-route'; 33 | import { 34 | gridLockToggle, gridRedLockToggle, gridRedHide, gridRedState, 35 | } from './grid-toggle'; 36 | import { colors } from './colors'; 37 | import { trees } from './tree'; 38 | import { initAudio, soundSetings, playSound } from './audio'; 39 | 40 | let updateCount = 0; 41 | let renderCount = 0; 42 | let totalUpdateCount = 0; 43 | let gameOverlayHidden; 44 | let lostFarmPosition; 45 | let gameStarted = false; 46 | 47 | const loop = GameLoop({ 48 | update() { 49 | if (gameStarted) { 50 | spawnNewObjects(totalUpdateCount, gameStarted); 51 | 52 | // if (totalUpdateCount === 90) { 53 | // gridRedToggleButton.style.opacity = 1; 54 | // } 55 | 56 | if (totalUpdateCount === 120) { 57 | scoreCounters.style.opacity = 1; 58 | } 59 | 60 | if (totalUpdateCount === 150) { 61 | pathTilesIndicator.style.opacity = 1; 62 | } 63 | 64 | if (totalUpdateCount === 180) { 65 | clock.style.opacity = 1; 66 | } 67 | 68 | if (totalUpdateCount === 210) { 69 | pauseButton.style.opacity = 1; 70 | } 71 | 72 | if (totalUpdateCount % (720 * 12) === 0 && inventory.paths < 99) { // 720 73 | pathTilesIndicator.style.scale = 1.1; 74 | 75 | pathTilesIndicatorCount.innerText = '+9'; 76 | 77 | setTimeout(() => pathTilesIndicatorCount.innerText = inventory.paths, 1300); 78 | 79 | for (let i = 0; i < 9; i++) { 80 | setTimeout(() => { 81 | if (inventory.paths < 99) { 82 | inventory.paths++; 83 | pathTilesIndicatorCount.innerText = inventory.paths; 84 | } 85 | }, 1300 + 100 * i); 86 | } 87 | 88 | setTimeout(() => { 89 | pathTilesIndicator.style.scale = 1; 90 | }, 300); 91 | } 92 | 93 | // Updating this at 60FPS is a bit much but rotates are usually on the GPU anyway 94 | clockHand.style.transform = `rotate(${totalUpdateCount / 2}deg)`; 95 | // switch (Math.floor(totalUpdateCount / 720 % 12)) { 96 | // case 0: clockMonth.innerText = 'Jan'; break; 97 | // case 1: clockMonth.innerText = 'Feb'; break; 98 | // case 2: clockMonth.innerText = 'Mar'; break; 99 | // case 3: clockMonth.innerText = 'Apr'; break; 100 | // case 4: clockMonth.innerText = 'May'; break; 101 | // case 5: clockMonth.innerText = 'Jun'; break; 102 | // case 6: clockMonth.innerText = 'Jul'; break; 103 | // case 7: clockMonth.innerText = 'Aug'; break; 104 | // case 8: clockMonth.innerText = 'Sep'; break; 105 | // case 9: clockMonth.innerText = 'Oct'; break; 106 | // case 10: clockMonth.innerText = 'Nov'; break; 107 | // case 11: clockMonth.innerText = 'Dec'; break; 108 | // } 109 | // Converted to from switch to if () for better compression 110 | if (Math.floor((totalUpdateCount / 720) % 12) === 0) { 111 | clockMonth.innerText = 'Jan'; 112 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 1) { 113 | clockMonth.innerText = 'Feb'; 114 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 2) { 115 | clockMonth.innerText = 'Mar'; 116 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 3) { 117 | clockMonth.innerText = 'Apr'; 118 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 4) { 119 | clockMonth.innerText = 'May'; 120 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 5) { 121 | clockMonth.innerText = 'Jun'; 122 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 6) { 123 | clockMonth.innerText = 'Jul'; 124 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 7) { 125 | clockMonth.innerText = 'Aug'; 126 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 8) { 127 | clockMonth.innerText = 'Sep'; 128 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 9) { 129 | clockMonth.innerText = 'Oct'; 130 | } else if (Math.floor((totalUpdateCount / 720) % 12) === 10) { 131 | clockMonth.innerText = 'Nov'; 132 | } else { 133 | clockMonth.innerText = 'Dec'; 134 | } 135 | } 136 | 137 | updateCount++; 138 | totalUpdateCount++; 139 | 140 | // Some things happen 15 times/s instead of 60. 141 | // E.g. because movement handled with CSS transitions will be done at browser FPS anyway 142 | /* eslint-disable default-case */ 143 | // switch (updateCount % 4) { 144 | // case 0: 145 | // // Update path grid data once every 4 updates (15 times per second) instead of 146 | // // every single time pathfinding is updated which was 6000 time per second(?) 147 | // updateGridData(); 148 | // break; 149 | // case 1: 150 | // oxFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 151 | // break; 152 | // case 2: 153 | // goatFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 154 | // break; 155 | // case 3: 156 | // fishFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 157 | // break; 158 | // } 159 | // Converted to from switch to if () for better compression 160 | if (updateCount % 4 === 0) { 161 | updateGridData(); 162 | } else if (updateCount % 4 === 1) { 163 | oxFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 164 | } else if (updateCount % 4 === 2) { 165 | goatFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 166 | } else { // 3 167 | fishFarms.forEach((farm) => farm.update(gameStarted, totalUpdateCount)); 168 | } 169 | 170 | if (updateCount >= 60) updateCount = 0; 171 | 172 | farms.forEach((f) => { 173 | if (!f.isAlive) { 174 | gameStarted = false; 175 | loop.stop(); 176 | 177 | lostFarmPosition = svgPxToDisplayPx( 178 | f.x - gridWidth / 2 - boardOffsetX + f.width / 2, 179 | f.y - gridHeight / 2 - boardOffsetY + f.height / 2, 180 | ); 181 | 182 | svgElement.style.transition = `transform 2s ease-out .5s`; 183 | svgElement.style.transform = `rotate(-17deg) scale(2) translate(${-lostFarmPosition.x}px, ${-lostFarmPosition.y}px)`; 184 | 185 | oxCounterWrapper.style.opacity = 0; 186 | goatCounterWrapper.style.opacity = 0; 187 | fishCounterWrapper.style.opacity = 0; 188 | clock.style.opacity = 0; 189 | pathTilesIndicator.style.opacity = 0; 190 | pauseButton.style.opacity = 0; 191 | gridRedState.on = false; 192 | gridRedState.buttonShown = false; 193 | gridRedHide(); 194 | 195 | updateCount = 0; 196 | totalUpdateCount = 0; 197 | renderCount = 0; 198 | // This isn't actually used for a while, so does end up defined. 199 | // It would still be good to sort it out in some way though... 200 | // eslint-disable-next-line no-use-before-define 201 | showGameover(startNewGame); 202 | } 203 | }); 204 | 205 | people.forEach((p) => p.update()); 206 | }, 207 | render() { 208 | renderCount++; 209 | 210 | // Some things happen 15 times/s instead of 60. 211 | // E.g. because movement handled with CSS transitions will be done at browser FPS anyway 212 | // switch (renderCount % 4) { 213 | // case 0: 214 | // break; 215 | // case 1: 216 | // oxFarms.forEach((farm) => farm.render()); 217 | // break; 218 | // case 2: 219 | // goatFarms.forEach((farm) => farm.render()); 220 | // break; 221 | // case 3: 222 | // fishFarms.forEach((farm) => farm.render()); 223 | // break; 224 | // } 225 | // Converted to from switch to if () for better compression 226 | if (renderCount % 4 === 1) { 227 | oxFarms.forEach((farm) => farm.render()); 228 | } else if (renderCount % 4 === 2) { 229 | goatFarms.forEach((farm) => farm.render()); 230 | } else { 231 | fishFarms.forEach((farm) => farm.render()); 232 | } 233 | 234 | if (renderCount >= 60) renderCount = 0; 235 | 236 | people.forEach((p) => p.render()); 237 | }, 238 | }); 239 | 240 | const startNewGame = () => { 241 | // Had to wrap this all in a gameStarted check, because restart button still exists 242 | // (and has focus!) so could be "pressed" with space bar to re-restart 243 | if (!gameStarted && loop.isStopped) { 244 | gameStarted = true; 245 | 246 | svgElement.style.transition = `transform 2s`; 247 | svgElement.style.transform = `rotate(0) scale(2) translate(0, ${svgPxToDisplayPx(0, gridHeight).y / -2}px)`; 248 | 249 | soundToggleButton.style.transition = `all .2s, width.5s 4s, opacity .5s 3s`; 250 | gridRedToggleButton.style.transition = `all .2s, width.5s 4s, opacity .5s 3s`; 251 | gridToggleButton.style.transition = `all .2s, width .5s 4s, opacity .5s 3s`; 252 | 253 | soundToggleTooltip.style.transition = `all .5s`; 254 | gridRedToggleTooltip.style.transition = `all .5s`; 255 | gridToggleTooltip.style.transition = `all .5s`; 256 | 257 | soundToggleButton.style.opacity = 1; 258 | gridRedToggleButton.style.opacity = 1; 259 | gridToggleButton.style.opacity = 1; 260 | 261 | oxCounterWrapper.style.width = 0; 262 | goatCounterWrapper.style.width = 0; 263 | fishCounterWrapper.style.width = 0; 264 | oxCounterWrapper.style.opacity = 0; 265 | goatCounterWrapper.style.opacity = 0; 266 | fishCounterWrapper.style.opacity = 0; 267 | oxCounter.innerText = 0; 268 | goatCounter.innerText = 0; 269 | fishCounter.innerText = 0; 270 | pauseButton.style.opacity = 0; 271 | 272 | toggleGameoverlayButton.style.opacity = 0; 273 | toggleGameoverlayButton.style.pointerEvents = 'none'; 274 | toggleGameoverlayButton.style.transition = `all .2s, opacity .5s`; 275 | 276 | setTimeout(() => { 277 | goatFarms.length = 0; 278 | oxFarms.length = 0; 279 | fishFarms.length = 0; 280 | people.length = 0; 281 | farms.length = 0; 282 | animals.length = 0; 283 | oxen.length = 0; 284 | goats.length = 0; 285 | fishes.length = 0; 286 | yurts.length = 0; 287 | paths.length = 0; 288 | ponds.length = 0; 289 | trees.length = 0; 290 | updateCount = 1; 291 | totalUpdateCount = 1; 292 | renderCount = 1; 293 | inventory.paths = 18; 294 | pathTilesIndicatorCount.innerText = inventory.paths; 295 | clearLayers(); 296 | hideGameover(); 297 | svgElement.style.transform = ''; 298 | 299 | setTimeout(() => { 300 | spawnNewObjects(0); 301 | loop.start(); 302 | }, 1000); 303 | }, 1000); 304 | } 305 | }; 306 | 307 | const gameoverToMenu = () => { 308 | gameStarted = false; 309 | 310 | svgElement.style.transition = `transform 2s`; 311 | svgElement.style.transform = `rotate(0) scale(2) translate(0, ${svgPxToDisplayPx(0, gridHeight).y / -2}px)`; 312 | 313 | inventory.paths = 18; 314 | 315 | oxCounterWrapper.style.width = 0; 316 | goatCounterWrapper.style.width = 0; 317 | fishCounterWrapper.style.width = 0; 318 | oxCounterWrapper.style.opacity = 0; 319 | goatCounterWrapper.style.opacity = 0; 320 | fishCounterWrapper.style.opacity = 0; 321 | oxCounter.innerText = 0; 322 | goatCounter.innerText = 0; 323 | fishCounter.innerText = 0; 324 | 325 | toggleGameoverlayButton.style.opacity = 0; 326 | toggleGameoverlayButton.style.pointerEvents = 'none'; 327 | toggleGameoverlayButton.style.transition = `all .2s, opacity .5s`; 328 | 329 | soundToggleTooltip.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 330 | gridRedToggleTooltip.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 331 | gridToggleTooltip.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 332 | soundToggleButton.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 333 | gridRedToggleButton.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 334 | gridToggleButton.style.transition = `all.2s,width.5s 4s,opacity.5s 4s`; 335 | 336 | soundToggleTooltip.style.width = '96px'; 337 | gridRedToggleTooltip.style.width = '96px'; 338 | gridToggleTooltip.style.width = '96px'; 339 | soundToggleTooltip.style.opacity = 1; 340 | gridRedToggleTooltip.style.opacity = 1; 341 | gridToggleTooltip.style.opacity = 1; 342 | soundToggleButton.style.opacity = 1; 343 | gridRedToggleButton.style.opacity = 1; 344 | gridToggleButton.style.opacity = 1; 345 | 346 | setTimeout(() => { 347 | goatFarms.length = 0; 348 | oxFarms.length = 0; 349 | fishFarms.length = 0; 350 | people.length = 0; 351 | farms.length = 0; 352 | animals.length = 0; 353 | oxen.length = 0; 354 | goats.length = 0; 355 | fishes.length = 0; 356 | yurts.length = 0; 357 | paths.length = 0; 358 | ponds.length = 0; 359 | trees.length = 0; 360 | updateCount = 0; 361 | totalUpdateCount = 0; 362 | renderCount = 0; 363 | clearLayers(); 364 | hideGameover(); 365 | svgElement.style.transform = ''; 366 | pathTilesIndicatorCount.innerText = inventory.paths; 367 | 368 | setTimeout(() => { 369 | spawnNewObjects(totalUpdateCount, gameStarted, 2000); 370 | showMenu(farms[0]); 371 | loop.start(); 372 | }, 750); 373 | }, 500); 374 | }; 375 | 376 | const toggleGameoverlay = () => { 377 | if (gameOverlayHidden) { 378 | gameOverlayHidden = false; 379 | svgElement.style.transform = `rotate(-17deg) scale(2) translate(${-lostFarmPosition.x}px, ${-lostFarmPosition.y}px)`; 380 | showGameover(); 381 | } else { 382 | gameOverlayHidden = true; 383 | svgElement.style.transform = ''; 384 | hideGameover(); 385 | } 386 | }; 387 | 388 | initUi(); 389 | initMenuBackground(); 390 | initGameover(startNewGame, gameoverToMenu, toggleGameoverlay); 391 | initPointer(); 392 | 393 | const startGame = () => { 394 | if (!gameStarted) { 395 | svgElement.style.transition = `transform 2s`; 396 | svgElement.style.transform = ''; 397 | pathTilesIndicatorCount.innerText = inventory.paths; 398 | hideMenu(); 399 | gameStarted = true; 400 | updateCount = 1; 401 | totalUpdateCount = 1; 402 | renderCount = 1; 403 | 404 | soundToggleTooltip.style.transition = `all.5s`; 405 | gridRedToggleTooltip.style.transition = `all.5s`; 406 | gridToggleTooltip.style.transition = `all.5s`; 407 | 408 | soundToggleButton.style.opacity = 1; 409 | gridRedToggleButton.style.opacity = 1; 410 | gridToggleButton.style.opacity = 1; 411 | } 412 | }; 413 | 414 | // demoColors(); 415 | initMenu(startGame); 416 | // spawnTrees(); 417 | spawnNewObjects(totalUpdateCount, 2500); 418 | 419 | showMenu(farms[0], true); 420 | 421 | const togglePause = () => { 422 | if (gameStarted && totalUpdateCount > 210) { 423 | if (loop.isStopped) { 424 | loop.start(); 425 | pauseSvgPath.setAttribute('d', 'M6 6 6 10M10 6 10 8 10 10'); 426 | pauseSvgPath.style.transform = 'rotate(180deg)'; 427 | } else { 428 | loop.stop(); 429 | pauseSvgPath.setAttribute('d', 'M7 6 7 10M7 6 10 8 7 10'); 430 | pauseSvgPath.style.transform = 'rotate(0)'; 431 | } 432 | } 433 | }; 434 | 435 | const toggleSound = () => { 436 | initAudio(); 437 | 438 | if (soundSetings.on) { 439 | soundSetings.on = false; 440 | localStorage.setItem('Tiny Yurtss', false); 441 | soundToggleSvgPathX.setAttribute('d', 'M11 7Q10 8 9 9M9 7Q10 8 11 9'); 442 | soundToggleSvgPathX.style.stroke = colors.red; 443 | soundToggleTooltip.innerHTML = 'Sound: Off'; 444 | } else { 445 | soundSetings.on = true; 446 | localStorage.setItem('Tiny Yurtss', true); 447 | soundToggleSvgPathX.setAttribute('d', 'M10 6Q12 8 10 10M10 6Q12 8 10 10'); 448 | soundToggleSvgPathX.style.stroke = colors.ui; 449 | soundToggleTooltip.innerHTML = 'Sound: On'; 450 | } 451 | 452 | // This returns before playing if soundSettings.on === false 453 | // frequencyIndex, noteLength, playbackRate, pingyness, volume, lowpass, highpass 454 | playSound(30, 1, 1, 1, 0.3, 1000, 1000); 455 | }; 456 | 457 | if (soundSetings.on) { 458 | soundToggleSvgPathX.setAttribute('d', 'M10 6Q12 8 10 10M10 6Q12 8 10 10'); 459 | soundToggleSvgPathX.style.stroke = colors.ui; 460 | soundToggleTooltip.innerHTML = 'Sound: On'; 461 | } else { 462 | soundToggleSvgPathX.setAttribute('d', 'M11 7Q10 8 9 9M9 7Q10 8 11 9'); 463 | soundToggleSvgPathX.style.stroke = colors.red; 464 | soundToggleTooltip.innerHTML = 'Sound: Off'; 465 | } 466 | 467 | pauseButton.addEventListener('click', togglePause); 468 | gridRedToggleButton.addEventListener('click', gridRedLockToggle); 469 | gridToggleButton.addEventListener('click', gridLockToggle); 470 | soundToggleButton.addEventListener('click', toggleSound); 471 | soundToggleTooltip.addEventListener('click', () => soundToggleButton.click()); 472 | gridRedToggleTooltip.addEventListener('click', () => gridRedToggleButton.click()); 473 | gridToggleTooltip.addEventListener('click', () => gridToggleButton.click()); 474 | 475 | document.addEventListener('keypress', (event) => { 476 | if (event.key === ' ') { 477 | // Prevent double-toggling by having the button be focused when pressing space 478 | if (event.target !== pauseButton) { 479 | togglePause(); 480 | } 481 | 482 | // Simulate :active styles 483 | pauseButton.style.transform = 'scale(.95)'; 484 | setTimeout(() => pauseButton.style.transform = '', 150); 485 | } 486 | 487 | // initAudio(); 488 | 489 | // if (event.key === 'o') playWarnNote(colors.ox); 490 | // if (event.key === 'g') playWarnNote(colors.goat); 491 | // if (event.key === 'f') playWarnNote(colors.fish); 492 | // if (event.key === 'p') playPathPlacementNote(); 493 | // if (event.key === 'r') playPathDeleteNote(); 494 | // if (event.key === 't') playTreeDeleteNote(); 495 | // if (event.key === 'y') playYurtSpawnNote(); 496 | // if (event.key === 'n') playOutOfPathsNote(); // 'n'o paths 497 | }); 498 | 499 | setTimeout(() => { 500 | loop.start(); 501 | }, 1000); 502 | --------------------------------------------------------------------------------